16 Commits

Author SHA1 Message Date
shuaiplus a2654dcde3 feat: enhance import/export feature description for completeness and clarity 2026-03-04 23:52:56 +08:00
shuaiplus cb662b7d70 feat: update import/export feature descriptions for clarity and completeness 2026-03-04 23:49:37 +08:00
shuaiplus 1ac063909f feat: improve import/export feature descriptions for clarity and consistency 2026-03-04 23:17:58 +08:00
shuaiplus 35dc239c25 feat: enhance import/export page with new layout and features 2026-03-04 23:07:03 +08:00
shuaiplus c99a558b5e feat: add support for SSH key fingerprint normalization and compatibility 2026-03-04 22:45:30 +08:00
shuaiplus 819734ce5c feat: add export and import functionality for Bitwarden and NodeWarden formats
- Implemented export formats for Bitwarden (JSON, encrypted JSON, ZIP) and NodeWarden (JSON).
- Added support for attachments in ciphers and introduced new types for handling attachments.
- Enhanced import formats to include Bitwarden ZIP and NodeWarden JSON.
- Updated internationalization strings for attachment-related features.
- Improved UI styles for attachment management and import summary display.
2026-03-04 01:03:49 +08:00
shuaiplus 7b4733d4c4 feat: implement folder management features including create, update, and delete actions 2026-03-03 21:03:16 +08:00
shuaiplus af56236dba Merge branch 'main' of https://github.com/shuaiplus/nodewarden 2026-03-03 20:30:28 +08:00
Zheng Li 3622c58680 fix: add build command to wrangler.toml for CI/CD compatibility 2026-03-03 20:30:06 +08:00
shuaiplus b5284e669a feat: add FIDO2 credentials support to CipherLogin and VaultDraft types
- Introduced CipherLoginPasskey interface to represent FIDO2 credentials with a creation date.
- Updated CipherLogin interface to include an optional fido2Credentials property.
- Modified VaultDraft interface to add loginFido2Credentials property for handling FIDO2 credentials.
2026-03-03 02:18:26 +08:00
shuaiplus 4da5525a1a fix: update 2FA support descriptions and improve error handling in TOTP actions 2026-03-02 22:36:10 +08:00
shuaiplus 16a7bcace9 fix: resolve merge conflict in twoFactorRequiredResponse function 2026-03-02 22:12:46 +08:00
shuaiplus f59e81de3a Merge branch 'main' of https://github.com/shuaiplus/nodewarden 2026-03-02 22:08:53 +08:00
shuaiplus 227d43194d fix: update two-factor provider constants for backward compatibility 2026-03-02 22:07:04 +08:00
copilot-swe-agent[bot] 3341a9ef74 fix: return numeric provider IDs in TwoFactorProviders for Android client compatibility
Co-authored-by: shuaiplus <100134295+shuaiplus@users.noreply.github.com>
2026-03-02 13:57:37 +08:00
shuaiplus d0c97ee573 fix: correct typo in README.md 2026-03-02 00:41:10 +08:00
23 changed files with 6114 additions and 98 deletions
+7 -3
View File
@@ -3,7 +3,7 @@
</p>
<p align="center">
运行在 Cloudflare Workers 的 Bitwarden 第三方服务端,兼容官方客户
运行在 Cloudflare Workers 的 Bitwarden 第三方服务端,兼容官方客户
</p>
[![Powered by Cloudflare](https://img.shields.io/badge/Powered%20by-Cloudflare-F38020?logo=cloudflare&logoColor=white)](https://workers.cloudflare.com/)
@@ -30,13 +30,13 @@ English[`README_EN.md`](./README_EN.md)
| 文件夹 / 收藏 | ✅ | ✅ | 常用管理能力可用 |
| 全量同步 `/api/sync` | ✅ | ✅ | 已做兼容与性能优化 |
| 附件上传/下载 | ✅ | ✅ | 基于 Cloudflare R2 |
| 导入功能 | ✅ | ✅ | 覆盖常见导入路径 |
| 导入导出功能 | ✅ | ✅ | 完整实现,含 Bitwarden 密码库+附件 ZIP 导入 |
| 网站图标代理 | ✅ | ✅ | 通过 `/icons/{hostname}/icon.png` |
| passkey、TOTP字段 | ❌ | ✅ |官方需要会员,我们的不需要 |
| Send | ✅ | ✅ | 已支持文本 Send 与文件 Send |
| 多用户 | ✅ | ✅ | 完整的用户管理,邀请机制 |
| 组织/集合/成员权限 | ✅ | ❌ | 没必要实现 |
| 登录 2FATOTP/WebAuthn/Duo/Email | ✅ | ⚠️ 部分支持 | 仅支持 TOTP(通过 `TOTP_SECRET` |
| 登录 2FATOTP/WebAuthn/Duo/Email | ✅ | ⚠️ 部分支持 | 仅支持用户级 TOTP |
| SSO / SCIM / 企业目录 | ✅ | ❌ | 没必要实现 |
| 紧急访问 | ✅ | ❌ | 没必要实现 |
| 管理后台 / 计费订阅 | ✅ | ❌ | 纯免费 |
@@ -109,6 +109,10 @@ npm run dev
**Q: 如何备份数据?**
A: 在客户端中选择「导出密码库」,保存 JSON 文件。
**Q: 导入导出支持哪些格式?**
A: 支持 Bitwarden `json/csv/密码库+附件 zip` 和 NodeWarden `密码库+附件 json`(均含加密模式),且导入下拉中看到的格式都可直接导入。
A: 另外支持直接导入 Bitwarden `密码库+附件 zip`,这条路径官方 Bitwarden Web 不支持。
**Q: 忘记主密码怎么办?**
A: 无法恢复,这是端到端加密的特性。建议妥善保管主密码。
+6 -3
View File
@@ -30,19 +30,18 @@
| Folders / Favorites | ✅ | ✅ | Common vault organization supported |
| Full sync `/api/sync` | ✅ | ✅ | Compatibility and performance optimized |
| Attachment upload/download | ✅ | ✅ | Backed by Cloudflare R2 |
| Import flow (common clients) | ✅ | ✅ | Common import paths covered |
| mport / export | ✅ | ✅ | Fully implemented, including Bitwarden vault + attachments ZIP import. |
| Website icon proxy | ✅ | ✅ | Via `/icons/{hostname}/icon.png` |
| passkey、TOTP fields | ❌ | ✅ | Official service requires premium; NodeWarden does not |
| Multi-user | ✅ | ✅ | Full user management with invitation mechanism |
| Send | ✅ | ✅ | Text Send and File Send are supported |
| Organizations / Collections / Member roles | ✅ | ❌ | Not necessary to implement |
| Login 2FA (TOTP/WebAuthn/Duo/Email) | ✅ | ⚠️ Partial | TOTP-only via `TOTP_SECRET` |
| Login 2FA (TOTP/WebAuthn/Duo/Email) | ✅ | ⚠️ Partial | User-level TOTP only |
| SSO / SCIM / Enterprise directory | ✅ | ❌ | Not necessary to implement |
| Emergency access | ✅ | ❌ | Not necessary to implement |
| Admin console / Billing & subscription | ✅ | ❌ | Free only |
| Full push notification pipeline | ✅ | ❌ | Not necessary to implement |
## Tested clients / platforms
- ✅ Windows desktop client (v2026.1.0)
@@ -111,6 +110,10 @@ npm run dev
**Q: How do I back up my data?**
A: Use **Export vault** in your client and save the JSON file.
**Q: Which import/export formats are supported?**
A: NodeWarden supports Bitwarden `json/csv/vault + attachments zip` and NodeWarden `vault + attachments json` in both plain and encrypted modes, and every format visible in the import selector is directly importable.
A: It also supports direct import of Bitwarden `vault + attachments zip`, which is not directly supported by official Bitwarden Web import.
**Q: What if I forget the master password?**
A: It cant be recovered (end-to-end encryption). Keep it safe.
+33
View File
@@ -9,7 +9,10 @@
"version": "1.1.0",
"license": "LGPL-3.0",
"dependencies": {
"@noble/hashes": "^2.0.1",
"@tanstack/react-query": "^5.90.21",
"@zip.js/zip.js": "^2.8.22",
"fflate": "^0.8.2",
"lucide-preact": "^0.575.0",
"preact": "^10.28.4",
"qrcode-generator": "^2.0.4",
@@ -1519,6 +1522,18 @@
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
"node_modules/@noble/hashes": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/@noble/hashes/-/hashes-2.0.1.tgz",
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@poppinss/colors": {
"version": "4.1.6",
"resolved": "https://registry.npmmirror.com/@poppinss/colors/-/colors-4.1.6.tgz",
@@ -2075,6 +2090,17 @@
"undici-types": "~7.16.0"
}
},
"node_modules/@zip.js/zip.js": {
"version": "2.8.22",
"resolved": "https://registry.npmmirror.com/@zip.js/zip.js/-/zip.js-2.8.22.tgz",
"integrity": "sha512-0KlzbVR6r8irIX2o3zvUlosBDef62VDl47oUfa1U/qgEs67h4/eGBrX/6HWa1RQbt+J6sAeVmtyFKbTHNdF8qQ==",
"license": "BSD-3-Clause",
"engines": {
"bun": ">=0.7.0",
"deno": ">=1.0.0",
"node": ">=18.0.0"
}
},
"node_modules/babel-plugin-transform-hook-names": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/babel-plugin-transform-hook-names/-/babel-plugin-transform-hook-names-1.0.2.tgz",
@@ -2413,6 +2439,12 @@
}
}
},
"node_modules/fflate": {
"version": "0.8.2",
"resolved": "https://registry.npmmirror.com/fflate/-/fflate-0.8.2.tgz",
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
"license": "MIT"
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
@@ -3119,6 +3151,7 @@
"dev": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"workerd": "bin/workerd"
},
+5 -6
View File
@@ -7,13 +7,9 @@
"main": "src/index.ts",
"type": "module",
"scripts": {
"dev": "npm run web:build && wrangler dev -c wrangler.toml",
"dev:worker": "wrangler dev -c wrangler.toml",
"web:dev": "vite --config webapp/vite.config.ts",
"web:build": "vite build --config webapp/vite.config.ts",
"web:typecheck": "tsc -p webapp/tsconfig.json --noEmit",
"dev": "wrangler dev -c wrangler.toml",
"build": "vite build --config webapp/vite.config.ts",
"deploy": "npm run build && wrangler deploy"
"deploy": "wrangler deploy"
},
"keywords": [
"bitwarden",
@@ -45,7 +41,10 @@
"wrangler": "^4.69.0"
},
"dependencies": {
"@noble/hashes": "^2.0.1",
"@tanstack/react-query": "^5.90.21",
"@zip.js/zip.js": "^2.8.22",
"fflate": "^0.8.2",
"lucide-preact": "^0.575.0",
"preact": "^10.28.4",
"qrcode-generator": "^2.0.4",
+1 -1
View File
@@ -71,7 +71,7 @@ function toProfile(user: User, env: Env): ProfileResponse {
usesKeyConnector: false,
masterPasswordHint: null,
culture: 'en-US',
twoFactorEnabled: !!user.totpSecret || isTotpEnabled(env.TOTP_SECRET),
twoFactorEnabled: !!user.totpSecret,
key: user.key,
privateKey: user.privateKey,
accountKeys: null,
+26
View File
@@ -40,6 +40,28 @@ export function normalizeCipherLoginForCompatibility(login: any): any {
};
}
// 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 {
if (!sshKey || typeof sshKey !== 'object') return sshKey ?? null;
const candidate =
sshKey.keyFingerprint !== undefined && sshKey.keyFingerprint !== null
? sshKey.keyFingerprint
: sshKey.fingerprint;
const normalizedFingerprint =
candidate === undefined || candidate === null
? ''
: String(candidate);
return {
...sshKey,
keyFingerprint: normalizedFingerprint,
fingerprint: normalizedFingerprint,
};
}
// Format attachments for API response
export function formatAttachments(attachments: Attachment[]): any[] | null {
if (attachments.length === 0) return null;
@@ -63,6 +85,7 @@ export function cipherToResponse(cipher: Cipher, attachments: Attachment[] = [])
// 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 normalizedSshKey = normalizeCipherSshKeyForCompatibility((passthrough as any).sshKey ?? null);
return {
// Pass through ALL stored cipher fields (known + unknown)
@@ -85,6 +108,7 @@ export function cipherToResponse(cipher: Cipher, attachments: Attachment[] = [])
collectionIds: [],
attachments: formatAttachments(attachments),
login: normalizedLogin,
sshKey: normalizedSshKey,
encryptedFor: null,
};
}
@@ -181,6 +205,7 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
deletedAt: null,
};
cipher.login = normalizeCipherLoginForCompatibility(cipher.login);
cipher.sshKey = normalizeCipherSshKeyForCompatibility(cipher.sshKey);
const createFields = getAliasedProp(cipherData, ['fields', 'Fields']);
cipher.fields = createFields.present ? (createFields.value ?? null) : (cipher.fields ?? null);
@@ -232,6 +257,7 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
deletedAt: existingCipher.deletedAt,
};
cipher.login = normalizeCipherLoginForCompatibility(cipher.login);
cipher.sshKey = normalizeCipherSshKeyForCompatibility(cipher.sshKey);
// Custom fields deletion compatibility:
// - Accept both camelCase "fields" and PascalCase "Fields".
+16 -16
View File
@@ -13,21 +13,22 @@ import { issueSendAccessToken } from './sends';
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
const TWO_FACTOR_PROVIDER_REMEMBER = 5;
const TWO_FACTOR_PROVIDER_RECOVERY_CODE = 8;
// Android client (2026.2.x) deserializes TwoFactorProviders2 keys with -1 for recovery code.
// Keep request parsing backward-compatible with historical provider values (8 / 100).
const TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE = '-1';
const TWO_FACTOR_PROVIDER_RECOVERY_CODE_LEGACY = 8;
const TWO_FACTOR_PROVIDER_RECOVERY_CODE_ANDROID_REQUEST = 100;
function resolveTotpSecret(userSecret: string | null, envSecret: string | undefined): string | null {
function resolveTotpSecret(userSecret: string | null): string | null {
if (userSecret && isTotpEnabled(userSecret)) {
return userSecret;
}
if (isTotpEnabled(envSecret)) {
return envSecret!;
}
return null;
}
function twoFactorRequiredResponse(message: string = 'Two factor required.', includeRecoveryCode: boolean = false): Response {
const providers = includeRecoveryCode
? [String(TWO_FACTOR_PROVIDER_AUTHENTICATOR), String(TWO_FACTOR_PROVIDER_RECOVERY_CODE)]
? [String(TWO_FACTOR_PROVIDER_AUTHENTICATOR), TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE]
: [String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)];
const providers2: Record<string, null> = {};
for (const provider of providers) providers2[provider] = null;
@@ -151,9 +152,9 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
);
}
// Optional 2FA: enabled per-user secret first, then falls back to global env secret for compatibility.
// Optional 2FA: enabled only by per-user secret.
let trustedTwoFactorTokenToReturn: string | undefined;
const effectiveTotpSecret = resolveTotpSecret(user.totpSecret, env.TOTP_SECRET);
const effectiveTotpSecret = resolveTotpSecret(user.totpSecret);
if (effectiveTotpSecret) {
const canUseRecoveryCode = !!user.totpRecoveryCode;
const normalizedTwoFactorProvider = String(twoFactorProvider ?? '').trim();
@@ -168,13 +169,8 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
return twoFactorRequiredResponse('Two factor required.', canUseRecoveryCode);
}
const parsedProvider = Number.parseInt(normalizedTwoFactorProvider, 10);
if (!Number.isFinite(parsedProvider)) {
return twoFactorRequiredResponse('Two factor required.', canUseRecoveryCode);
}
let passedByRememberToken = false;
if (parsedProvider === TWO_FACTOR_PROVIDER_REMEMBER) {
if (normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_REMEMBER)) {
if (deviceInfo.deviceIdentifier) {
const trustedUserId = await storage.getTrustedTwoFactorDeviceTokenUserId(
normalizedTwoFactorToken,
@@ -187,12 +183,16 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
if (!passedByRememberToken) {
return twoFactorRequiredResponse('Two factor required.', canUseRecoveryCode);
}
} else if (parsedProvider === TWO_FACTOR_PROVIDER_AUTHENTICATOR) {
} else if (normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)) {
const totpOk = await verifyTotpToken(effectiveTotpSecret, normalizedTwoFactorToken);
if (!totpOk) {
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
}
} else if (parsedProvider === TWO_FACTOR_PROVIDER_RECOVERY_CODE) {
} else if (
normalizedTwoFactorProvider === TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE ||
normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_RECOVERY_CODE_LEGACY) ||
normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_RECOVERY_CODE_ANDROID_REQUEST)
) {
if (!recoveryCodeEquals(normalizedTwoFactorToken, user.totpRecoveryCode)) {
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
}
+17 -3
View File
@@ -1,13 +1,14 @@
import { Env, Cipher, Folder, CipherType } from '../types';
import { StorageService } from '../services/storage';
import { errorResponse } from '../utils/response';
import { errorResponse, jsonResponse } from '../utils/response';
import { generateUUID } from '../utils/uuid';
import { LIMITS } from '../config/limits';
import { normalizeCipherLoginForCompatibility } from './ciphers';
import { normalizeCipherLoginForCompatibility, normalizeCipherSshKeyForCompatibility } from './ciphers';
// Bitwarden client import request format
interface CiphersImportRequest {
ciphers: Array<{
id?: string | null;
type: number;
name?: string | null;
notes?: string | null;
@@ -90,6 +91,8 @@ async function runBatchInChunks(db: D1Database, statements: D1PreparedStatement[
// POST /api/ciphers/import - Bitwarden client import endpoint
export async function handleCiphersImport(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
const url = new URL(request.url);
const returnCipherMap = url.searchParams.get('returnCipherMap') === '1';
let importData: CiphersImportRequest;
try {
@@ -151,9 +154,12 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
// Create ciphers
const cipherRows: Cipher[] = [];
const cipherMapRows: Array<{ index: number; sourceId: string | null; id: string }> = [];
for (let i = 0; i < ciphers.length; i++) {
const c = ciphers[i];
const folderId = cipherFolderMap.get(i) || null;
const sourceIdRaw = String(c?.id ?? '').trim();
const sourceId = sourceIdRaw || null;
const cipher: Cipher = {
...c,
@@ -220,7 +226,7 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
})) || null,
passwordHistory: c.passwordHistory ?? null,
reprompt: c.reprompt ?? 0,
sshKey: (c as any).sshKey ?? null,
sshKey: normalizeCipherSshKeyForCompatibility((c as any).sshKey ?? null),
key: (c as any).key ?? null,
createdAt: now,
updatedAt: now,
@@ -229,6 +235,7 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
cipher.login = normalizeCipherLoginForCompatibility(cipher.login);
cipherRows.push(cipher);
cipherMapRows.push({ index: i, sourceId, id: cipher.id });
}
if (cipherRows.length > 0) {
@@ -263,5 +270,12 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
// Update revision date
await storage.updateRevisionDate(userId);
if (returnCipherMap) {
return jsonResponse({
object: 'import-result',
cipherMap: cipherMapRows,
});
}
return new Response(null, { status: 200 });
}
+1 -2
View File
@@ -4,7 +4,6 @@ import { errorResponse } from '../utils/response';
import { cipherToResponse } from './ciphers';
import { sendToResponse } from './sends';
import { LIMITS } from '../config/limits';
import { isTotpEnabled } from '../utils/totp';
interface SyncCacheEntry {
body: string;
@@ -76,7 +75,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
usesKeyConnector: false,
masterPasswordHint: null,
culture: 'en-US',
twoFactorEnabled: !!user.totpSecret || isTotpEnabled(env.TOTP_SECRET),
twoFactorEnabled: !!user.totpSecret,
key: user.key,
privateKey: user.privateKey,
accountKeys: null,
+781 -13
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'preact/hooks';
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { Link, Route, Switch, useLocation } from 'wouter';
import { useQuery } from '@tanstack/react-query';
import { ArrowUpDown, Cloud, Lock, LogOut, Send as SendIcon, Settings as SettingsIcon, Shield, ShieldUser, Vault } from 'lucide-preact';
@@ -14,22 +14,30 @@ import SettingsPage from '@/components/SettingsPage';
import SecurityDevicesPage from '@/components/SecurityDevicesPage';
import AdminPage from '@/components/AdminPage';
import HelpPage from '@/components/HelpPage';
import ImportExportPage from '@/components/ImportExportPage';
import ImportPage from '@/components/ImportPage';
import type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage';
import {
changeMasterPassword,
createFolder,
updateFolder,
deleteCipherAttachment,
deleteFolder,
createCipher,
createAuthedFetch,
createInvite,
downloadCipherAttachmentDecrypted,
importCiphers,
createSend,
deleteAllInvites,
deleteCipher,
deleteSend,
deleteUser,
deriveLoginHash,
getAttachmentDownloadInfo,
bulkMoveCiphers,
getCiphers,
getFolders,
getPreloginKdfConfig,
getProfile,
getAuthorizedDevices,
getSetupStatus,
@@ -50,14 +58,30 @@ import {
setTotp,
setUserStatus,
deleteAuthorizedDevice,
uploadCipherAttachment,
updateCipher,
updateSend,
buildSendShareKey,
unlockVaultKey,
verifyMasterPassword,
type ImportedCipherMapEntry,
} from '@/lib/api';
import { base64ToBytes, decryptBw, decryptStr, hkdf } from '@/lib/crypto';
import { base64ToBytes, decryptBw, decryptBwFileData, decryptStr, hkdf } from '@/lib/crypto';
import {
attachNodeWardenEncryptedAttachmentPayload,
buildAccountEncryptedBitwardenJsonString,
buildBitwardenZipBytes,
buildExportFileName,
buildNodeWardenAttachmentRecords,
buildNodeWardenPlainJsonDocument,
buildPasswordProtectedBitwardenJsonString,
buildPlainBitwardenJsonString,
encryptZipBytesWithPassword,
type ExportRequest,
type ZipAttachmentEntry,
} from '@/lib/export-formats';
import { t } from '@/lib/i18n';
import type { CiphersImportPayload } from '@/lib/api';
import type { AppPhase, AuthorizedDevice, Cipher, Folder, Profile, Send, SendDraft, SessionState, ToastMessage, VaultDraft } from '@/lib/types';
interface PendingTotp {
@@ -70,6 +94,168 @@ type JwtUnsafeReason = 'missing' | 'default' | 'too_short';
const SEND_KEY_SALT = 'bitwarden-send';
const SEND_KEY_PURPOSE = 'send';
const IMPORT_ROUTE = '/help/import-export';
const IMPORT_ROUTE_ALIASES = new Set(['/tools/import', '/tools/import-export', '/tools/import-data', '/import', '/import-export']);
function looksLikeCipherString(value: string): boolean {
return /^\d+\.[A-Za-z0-9+/=]+\|[A-Za-z0-9+/=]+(?:\|[A-Za-z0-9+/=]+)?$/.test(String(value || '').trim());
}
function asText(value: unknown): string {
if (value === null || value === undefined) return '';
return String(value);
}
function summarizeImportResult(
ciphers: Array<Record<string, unknown>>,
folderCount: number
): ImportResultSummary {
const counter = new Map<string, number>();
const typeLabel = (type: number): string => {
if (type === 1) return '登录';
if (type === 2) return '安全备注';
if (type === 3) return '卡片';
if (type === 4) return '身份';
if (type === 5) return 'SSH 密钥';
return '其他';
};
for (const raw of ciphers) {
const t = Number(raw?.type || 1) || 1;
const label = typeLabel(t);
counter.set(label, (counter.get(label) || 0) + 1);
}
const order = ['登录', '安全备注', '卡片', '身份', 'SSH 密钥', '其他'];
const typeCounts = order
.filter((label) => (counter.get(label) || 0) > 0)
.map((label) => ({ label, count: counter.get(label) || 0 }));
return {
totalItems: ciphers.length,
folderCount: Math.max(0, folderCount),
typeCounts,
};
}
function buildEmptyImportDraft(type: number): VaultDraft {
return {
type,
favorite: false,
name: '',
folderId: '',
notes: '',
reprompt: false,
loginUsername: '',
loginPassword: '',
loginTotp: '',
loginUris: [''],
loginFido2Credentials: [],
cardholderName: '',
cardNumber: '',
cardBrand: '',
cardExpMonth: '',
cardExpYear: '',
cardCode: '',
identTitle: '',
identFirstName: '',
identMiddleName: '',
identLastName: '',
identUsername: '',
identCompany: '',
identSsn: '',
identPassportNumber: '',
identLicenseNumber: '',
identEmail: '',
identPhone: '',
identAddress1: '',
identAddress2: '',
identAddress3: '',
identCity: '',
identState: '',
identPostalCode: '',
identCountry: '',
sshPrivateKey: '',
sshPublicKey: '',
sshFingerprint: '',
customFields: [],
};
}
function importCipherToDraft(cipher: Record<string, unknown>, folderId: string | null): VaultDraft {
const type = Number(cipher.type || 1) || 1;
const draft = buildEmptyImportDraft(type);
draft.name = asText(cipher.name).trim() || 'Untitled';
draft.notes = asText(cipher.notes);
draft.favorite = !!cipher.favorite;
draft.reprompt = Number(cipher.reprompt || 0) === 1;
draft.folderId = folderId || '';
const customFieldsRaw = Array.isArray(cipher.fields) ? cipher.fields : [];
draft.customFields = customFieldsRaw
.map((raw) => {
const field = (raw || {}) as Record<string, unknown>;
const label = asText(field.name).trim();
if (!label) return null;
const parsedType = Number(field.type ?? 0);
const fieldType = parsedType === 1 || parsedType === 2 || parsedType === 3 ? (parsedType as 1 | 2 | 3) : 0;
return {
type: fieldType,
label,
value: asText(field.value),
};
})
.filter((x): x is VaultDraft['customFields'][number] => !!x);
if (type === 1) {
const login = (cipher.login || {}) as Record<string, unknown>;
draft.loginUsername = asText(login.username);
draft.loginPassword = asText(login.password);
draft.loginTotp = asText(login.totp);
draft.loginFido2Credentials = Array.isArray(login.fido2Credentials)
? login.fido2Credentials
.filter((credential): credential is Record<string, unknown> => !!credential && typeof credential === 'object')
.map((credential) => ({ ...credential }))
: [];
const urisRaw = Array.isArray(login.uris) ? login.uris : [];
const uris = urisRaw
.map((u) => asText((u as Record<string, unknown>)?.uri).trim())
.filter((u) => !!u);
draft.loginUris = uris.length ? uris : [''];
} else if (type === 3) {
const card = (cipher.card || {}) as Record<string, unknown>;
draft.cardholderName = asText(card.cardholderName);
draft.cardNumber = asText(card.number);
draft.cardBrand = asText(card.brand);
draft.cardExpMonth = asText(card.expMonth);
draft.cardExpYear = asText(card.expYear);
draft.cardCode = asText(card.code);
} else if (type === 4) {
const identity = (cipher.identity || {}) as Record<string, unknown>;
draft.identTitle = asText(identity.title);
draft.identFirstName = asText(identity.firstName);
draft.identMiddleName = asText(identity.middleName);
draft.identLastName = asText(identity.lastName);
draft.identUsername = asText(identity.username);
draft.identCompany = asText(identity.company);
draft.identSsn = asText(identity.ssn);
draft.identPassportNumber = asText(identity.passportNumber);
draft.identLicenseNumber = asText(identity.licenseNumber);
draft.identEmail = asText(identity.email);
draft.identPhone = asText(identity.phone);
draft.identAddress1 = asText(identity.address1);
draft.identAddress2 = asText(identity.address2);
draft.identAddress3 = asText(identity.address3);
draft.identCity = asText(identity.city);
draft.identState = asText(identity.state);
draft.identPostalCode = asText(identity.postalCode);
draft.identCountry = asText(identity.country);
} else if (type === 5) {
const sshKey = (cipher.sshKey || {}) as Record<string, unknown>;
draft.sshPrivateKey = asText(sshKey.privateKey);
draft.sshPublicKey = asText(sshKey.publicKey);
draft.sshFingerprint = asText(sshKey.keyFingerprint ?? sshKey.fingerprint);
}
return draft;
}
function buildPublicSendUrl(origin: string, accessId: string, keyPart: string): string {
return `${origin}/#/send/${accessId}/${keyPart}`;
@@ -121,6 +307,7 @@ export default function App() {
const [decryptedFolders, setDecryptedFolders] = useState<Folder[]>([]);
const [decryptedCiphers, setDecryptedCiphers] = useState<Cipher[]>([]);
const [decryptedSends, setDecryptedSends] = useState<Send[]>([]);
const migratedPlainFolderIdsRef = useRef<Set<string>>(new Set());
function setSession(next: SessionState | null) {
setSessionState(next);
@@ -470,6 +657,9 @@ export default function App() {
decUsername: await decryptField(cipher.login.username || '', itemEnc, itemMac),
decPassword: await decryptField(cipher.login.password || '', itemEnc, itemMac),
decTotp: await decryptField(cipher.login.totp || '', itemEnc, itemMac),
fido2Credentials: Array.isArray(cipher.login.fido2Credentials)
? cipher.login.fido2Credentials.map((credential) => ({ ...credential }))
: null,
uris: await Promise.all(
(cipher.login.uris || []).map(async (u) => ({
...u,
@@ -513,11 +703,14 @@ export default function App() {
};
}
if (cipher.sshKey) {
const encryptedFingerprint = cipher.sshKey.keyFingerprint || cipher.sshKey.fingerprint || '';
nextCipher.sshKey = {
...cipher.sshKey,
decPrivateKey: await decryptField(cipher.sshKey.privateKey || '', itemEnc, itemMac),
decPublicKey: await decryptField(cipher.sshKey.publicKey || '', itemEnc, itemMac),
decFingerprint: await decryptField(cipher.sshKey.fingerprint || '', itemEnc, itemMac),
keyFingerprint: encryptedFingerprint || null,
fingerprint: encryptedFingerprint || null,
decFingerprint: await decryptField(encryptedFingerprint, itemEnc, itemMac),
};
}
if (cipher.fields) {
@@ -529,6 +722,14 @@ export default function App() {
}))
);
}
if (Array.isArray(cipher.attachments)) {
nextCipher.attachments = await Promise.all(
cipher.attachments.map(async (attachment) => ({
...attachment,
decFileName: await decryptField(attachment.fileName || '', itemEnc, itemMac),
}))
);
}
return nextCipher;
})
);
@@ -580,6 +781,31 @@ export default function App() {
};
}, [session?.symEncKey, session?.symMacKey, foldersQuery.data, ciphersQuery.data, sendsQuery.data]);
useEffect(() => {
if (!session?.symEncKey || !session?.symMacKey || !foldersQuery.data?.length) return;
let cancelled = false;
(async () => {
const pending = foldersQuery.data.filter((folder) => {
if (!folder?.id || !folder?.name) return false;
if (migratedPlainFolderIdsRef.current.has(folder.id)) return false;
return !looksLikeCipherString(String(folder.name));
});
if (!pending.length) return;
for (const folder of pending) {
try {
await updateFolder(authedFetch, session, folder.id, String(folder.name));
migratedPlainFolderIdsRef.current.add(folder.id);
} catch {
// keep silent; web still supports plaintext fallback display
}
}
if (!cancelled) await foldersQuery.refetch();
})();
return () => {
cancelled = true;
};
}, [session?.symEncKey, session?.symMacKey, foldersQuery.data, authedFetch]);
async function changePasswordAction(currentPassword: string, nextPassword: string, nextPassword2: string) {
if (!profile) return;
if (!currentPassword || !nextPassword) {
@@ -611,14 +837,16 @@ export default function App() {
async function enableTotpAction(secret: string, token: string) {
if (!secret.trim() || !token.trim()) {
pushToast('error', t('txt_secret_and_code_are_required'));
return;
const error = new Error(t('txt_secret_and_code_are_required'));
pushToast('error', error.message);
throw error;
}
try {
await setTotp(authedFetch, { enabled: true, secret: secret.trim(), token: token.trim() });
pushToast('success', t('txt_totp_enabled'));
} catch (error) {
pushToast('error', error instanceof Error ? error.message : t('txt_enable_totp_failed'));
throw error;
}
}
@@ -668,10 +896,13 @@ export default function App() {
pushToast('success', t('txt_device_removed'));
}
async function createVaultItem(draft: VaultDraft) {
async function createVaultItem(draft: VaultDraft, attachments: File[] = []) {
if (!session) return;
try {
await createCipher(authedFetch, session, draft);
const created = await createCipher(authedFetch, session, draft);
for (const file of attachments) {
await uploadCipherAttachment(authedFetch, session, created.id, file);
}
await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]);
pushToast('success', t('txt_item_created'));
} catch (error) {
@@ -680,10 +911,24 @@ export default function App() {
}
}
async function updateVaultItem(cipher: Cipher, draft: VaultDraft) {
async function updateVaultItem(
cipher: Cipher,
draft: VaultDraft,
options?: { addFiles?: File[]; removeAttachmentIds?: string[] }
) {
if (!session) return;
const addFiles = Array.isArray(options?.addFiles) ? options.addFiles : [];
const removeAttachmentIds = Array.isArray(options?.removeAttachmentIds) ? options.removeAttachmentIds : [];
try {
await updateCipher(authedFetch, session, cipher, draft);
for (const attachmentId of removeAttachmentIds) {
const id = String(attachmentId || '').trim();
if (!id) continue;
await deleteCipherAttachment(authedFetch, cipher.id, id);
}
for (const file of addFiles) {
await uploadCipherAttachment(authedFetch, session, cipher.id, file, cipher);
}
await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]);
pushToast('success', t('txt_item_updated'));
} catch (error) {
@@ -692,6 +937,29 @@ export default function App() {
}
}
async function downloadVaultAttachment(cipher: Cipher, attachmentId: string) {
if (!session) return;
try {
const file = await downloadCipherAttachmentDecrypted(authedFetch, session, cipher, attachmentId);
const fileName = String(file.fileName || '').trim() || 'attachment.bin';
const payload = new ArrayBuffer(file.bytes.byteLength);
new Uint8Array(payload).set(file.bytes);
const blob = new Blob([payload], { type: 'application/octet-stream' });
const href = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = href;
anchor.download = fileName;
anchor.rel = 'noopener';
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(href);
} catch (error) {
pushToast('error', error instanceof Error ? error.message : t('txt_download_failed'));
throw error;
}
}
async function deleteVaultItem(cipher: Cipher) {
try {
await deleteCipher(authedFetch, cipher.id);
@@ -807,7 +1075,8 @@ export default function App() {
return;
}
try {
await createFolder(authedFetch, folderName);
if (!session) throw new Error('Vault key unavailable');
await createFolder(authedFetch, session, folderName);
await foldersQuery.refetch();
pushToast('success', t('txt_folder_created'));
} catch (error) {
@@ -816,17 +1085,457 @@ export default function App() {
}
}
async function deleteFolderAction(folderId: string) {
const id = String(folderId || '').trim();
if (!id) {
pushToast('error', 'Folder not found');
return;
}
try {
await deleteFolder(authedFetch, id);
await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]);
pushToast('success', 'Folder deleted');
} catch (error) {
pushToast('error', error instanceof Error ? error.message : 'Delete folder failed');
throw error;
}
}
function buildImportedCipherMaps(
payloadCiphers: Array<Record<string, unknown>>,
createdCipherIdsByIndex: Map<number, string>
): { byIndex: Map<number, string>; bySourceId: Map<string, string> } {
const byIndex = new Map<number, string>(createdCipherIdsByIndex);
const bySourceId = new Map<string, string>();
for (const [index, id] of createdCipherIdsByIndex.entries()) {
const raw = (payloadCiphers[index] || {}) as Record<string, unknown>;
const sourceId = String(raw.id || '').trim();
if (sourceId) bySourceId.set(sourceId, id);
}
return { byIndex, bySourceId };
}
async function uploadImportedAttachments(
attachments: ImportAttachmentFile[],
idMaps: { byIndex: Map<number, string>; bySourceId: Map<string, string> }
): Promise<void> {
if (!attachments.length) return;
if (!session?.symEncKey || !session?.symMacKey) throw new Error('Vault key unavailable');
const initialCiphers = (await ciphersQuery.refetch()).data || [];
const cipherById = new Map(initialCiphers.map((cipher) => [String(cipher.id || ''), cipher]));
const unresolved: ImportAttachmentFile[] = [];
for (const attachment of attachments) {
const sourceId = String(attachment.sourceCipherId || '').trim();
const sourceIndex = Number(attachment.sourceCipherIndex);
const byId = sourceId ? idMaps.bySourceId.get(sourceId) : null;
const byIndex = Number.isFinite(sourceIndex) ? idMaps.byIndex.get(sourceIndex) : null;
const targetCipherId = byId || byIndex || null;
if (!targetCipherId) {
unresolved.push(attachment);
continue;
}
const name = String(attachment.fileName || '').trim() || 'attachment.bin';
const fileBytes = Uint8Array.from(attachment.bytes);
const file = new File([fileBytes], name, { type: 'application/octet-stream' });
const cipher = cipherById.get(targetCipherId) || null;
await uploadCipherAttachment(authedFetch, session, targetCipherId, file, cipher);
}
if (unresolved.length) {
throw new Error(`Failed to map ${unresolved.length} attachment(s) to imported items.`);
}
await ciphersQuery.refetch();
}
function toImportedCipherMapsFromResponse(
cipherMap: ImportedCipherMapEntry[] | null
): { byIndex: Map<number, string>; bySourceId: Map<string, string> } {
const byIndex = new Map<number, string>();
const bySourceId = new Map<string, string>();
for (const row of cipherMap || []) {
const idx = Number(row?.index);
const id = String(row?.id || '').trim();
if (!Number.isFinite(idx) || !id) continue;
byIndex.set(idx, id);
const sourceId = String(row?.sourceId || '').trim();
if (sourceId) bySourceId.set(sourceId, id);
}
return { byIndex, bySourceId };
}
async function handleImportAction(
payload: CiphersImportPayload,
options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null },
attachments: ImportAttachmentFile[] = []
): Promise<ImportResultSummary> {
if (!session?.symEncKey || !session?.symMacKey) throw new Error('Vault key unavailable');
const mode = options.folderMode || 'original';
const targetFolderId = (options.targetFolderId || '').trim() || null;
const folderIdByCipherIndex = new Map<number, string>();
let createdFolderCount = 0;
if (mode === 'original') {
const folderIdByImportIndex = new Map<number, string>();
const folderIdByLegacyId = new Map<string, string>();
const folderIdByName = new Map<string, string>();
const createdFolderIdByName = new Map<string, string>();
for (let i = 0; i < payload.folders.length; i++) {
const folderRaw = (payload.folders[i] || {}) as Record<string, unknown>;
const name = String(folderRaw.name || '').trim();
if (!name) continue;
let folderId = createdFolderIdByName.get(name) || null;
if (!folderId) {
const created = await createFolder(authedFetch, session, name);
folderId = created.id;
createdFolderIdByName.set(name, folderId);
createdFolderCount += 1;
}
folderIdByImportIndex.set(i, folderId);
folderIdByName.set(name, folderId);
const legacyId = String(folderRaw.id || '').trim();
if (legacyId) folderIdByLegacyId.set(legacyId, folderId);
}
for (const relation of payload.folderRelationships || []) {
const cipherIndex = Number(relation?.key);
const folderIndex = Number(relation?.value);
if (!Number.isFinite(cipherIndex) || !Number.isFinite(folderIndex)) continue;
const folderId = folderIdByImportIndex.get(folderIndex);
if (folderId) folderIdByCipherIndex.set(cipherIndex, folderId);
}
for (let i = 0; i < payload.ciphers.length; i++) {
if (folderIdByCipherIndex.has(i)) continue;
const raw = (payload.ciphers[i] || {}) as Record<string, unknown>;
const rawFolderId = String(raw.folderId || '').trim();
if (rawFolderId && folderIdByLegacyId.has(rawFolderId)) {
folderIdByCipherIndex.set(i, folderIdByLegacyId.get(rawFolderId)!);
continue;
}
const rawFolderName = String(raw.folder || '').trim();
if (rawFolderName && folderIdByName.has(rawFolderName)) {
folderIdByCipherIndex.set(i, folderIdByName.get(rawFolderName)!);
}
}
} else if (mode === 'target' && targetFolderId) {
for (let i = 0; i < payload.ciphers.length; i++) {
folderIdByCipherIndex.set(i, targetFolderId);
}
}
const createdCipherIdsByIndex = new Map<number, string>();
for (let i = 0; i < payload.ciphers.length; i++) {
const raw = (payload.ciphers[i] || {}) as Record<string, unknown>;
const draft = importCipherToDraft(raw, null);
const created = await createCipher(authedFetch, session, draft);
createdCipherIdsByIndex.set(i, created.id);
}
const moveIdsByFolderId = new Map<string, string[]>();
for (const [index, folderId] of folderIdByCipherIndex.entries()) {
const cipherId = createdCipherIdsByIndex.get(index);
if (!cipherId || !folderId) continue;
const group = moveIdsByFolderId.get(folderId) || [];
group.push(cipherId);
moveIdsByFolderId.set(folderId, group);
}
for (const [folderId, ids] of moveIdsByFolderId.entries()) {
await bulkMoveCiphers(authedFetch, ids, folderId);
}
const idMaps = buildImportedCipherMaps(payload.ciphers, createdCipherIdsByIndex);
await foldersQuery.refetch();
await ciphersQuery.refetch();
if (attachments.length) {
await uploadImportedAttachments(attachments, idMaps);
}
return summarizeImportResult(payload.ciphers, mode === 'original' ? createdFolderCount : 0);
}
async function handleImportEncryptedRawAction(
payload: CiphersImportPayload,
options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null },
attachments: ImportAttachmentFile[] = []
): Promise<ImportResultSummary> {
const mode = options.folderMode || 'original';
const targetFolderId = (options.targetFolderId || '').trim() || null;
const nextPayload: CiphersImportPayload = {
ciphers: payload.ciphers.map((raw) => ({ ...(raw as Record<string, unknown>) })),
folders: mode === 'original' ? payload.folders : [],
folderRelationships: mode === 'original' ? payload.folderRelationships : [],
};
if (mode === 'none') {
for (const raw of nextPayload.ciphers) (raw as Record<string, unknown>).folderId = null;
} else if (mode === 'target' && targetFolderId) {
for (const raw of nextPayload.ciphers) (raw as Record<string, unknown>).folderId = targetFolderId;
}
const importedCipherMap = await importCiphers(authedFetch, nextPayload, {
returnCipherMap: attachments.length > 0,
});
await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]);
if (attachments.length) {
const idMaps = toImportedCipherMapsFromResponse(importedCipherMap);
await uploadImportedAttachments(attachments, idMaps);
}
return summarizeImportResult(nextPayload.ciphers, mode === 'original' ? nextPayload.folders.length : 0);
}
async function handleExportAction(request: ExportRequest) {
if (!session?.symEncKey || !session?.symMacKey) throw new Error('Vault key unavailable');
const masterPassword = String(request.masterPassword || '').trim();
if (!masterPassword) throw new Error(t('txt_master_password_is_required'));
const email = String(profile?.email || session.email || '').trim().toLowerCase();
if (!email) throw new Error(t('txt_profile_unavailable'));
const verifyDerived = await deriveLoginHash(email, masterPassword, defaultKdfIterations);
await verifyMasterPassword(authedFetch, verifyDerived.hash);
const rawFolders = foldersQuery.data || [];
const rawCiphers = ciphersQuery.data || [];
if (!rawFolders || !rawCiphers) throw new Error('Vault is not ready yet');
let plainJsonCache: string | null = null;
let plainJsonDocCache: Record<string, unknown> | null = null;
let encryptedJsonCache: string | null = null;
let nodeWardenAttachmentsCache: ReturnType<typeof buildNodeWardenAttachmentRecords> | null = null;
const getPlainJson = async () => {
if (!plainJsonCache) {
plainJsonCache = await buildPlainBitwardenJsonString({
folders: rawFolders,
ciphers: rawCiphers,
userEncB64: session.symEncKey!,
userMacB64: session.symMacKey!,
});
}
return plainJsonCache;
};
const getPlainJsonDoc = async () => {
if (!plainJsonDocCache) {
plainJsonDocCache = JSON.parse(await getPlainJson()) as Record<string, unknown>;
}
return plainJsonDocCache;
};
const getEncryptedJson = async () => {
if (!encryptedJsonCache) {
encryptedJsonCache = await buildAccountEncryptedBitwardenJsonString({
folders: rawFolders,
ciphers: rawCiphers,
userEncB64: session.symEncKey!,
userMacB64: session.symMacKey!,
});
}
return encryptedJsonCache;
};
const zipAttachments = async (): Promise<ZipAttachmentEntry[]> => {
const userEnc = base64ToBytes(session.symEncKey!);
const userMac = base64ToBytes(session.symMacKey!);
const out: ZipAttachmentEntry[] = [];
const activeCiphers = rawCiphers.filter((cipher) => !cipher.deletedDate && !(cipher as { organizationId?: unknown }).organizationId);
for (const cipher of activeCiphers) {
const cipherId = String(cipher.id || '').trim();
if (!cipherId) continue;
const attachments = Array.isArray(cipher.attachments) ? cipher.attachments : [];
if (!attachments.length) continue;
let itemEnc = userEnc;
let itemMac = userMac;
const itemKey = String(cipher.key || '').trim();
if (itemKey && looksLikeCipherString(itemKey)) {
try {
const rawItemKey = await decryptBw(itemKey, userEnc, userMac);
if (rawItemKey.length >= 64) {
itemEnc = rawItemKey.slice(0, 32);
itemMac = rawItemKey.slice(32, 64);
}
} catch {
// fallback to user key
}
}
for (const attachment of attachments) {
const attachmentId = String(attachment?.id || '').trim();
if (!attachmentId) continue;
const info = await getAttachmentDownloadInfo(authedFetch, cipherId, attachmentId);
const fileResp = await fetch(info.url, { cache: 'no-store' });
if (!fileResp.ok) throw new Error(`Failed to download attachment ${attachmentId}`);
const encryptedBytes = new Uint8Array(await fileResp.arrayBuffer());
let fileEnc = itemEnc;
let fileMac = itemMac;
const attachmentKeyCipher = String(info.key || attachment?.key || '').trim();
if (attachmentKeyCipher && looksLikeCipherString(attachmentKeyCipher)) {
try {
const rawAttachmentKey = await decryptBw(attachmentKeyCipher, itemEnc, itemMac);
if (rawAttachmentKey.length >= 64) {
fileEnc = rawAttachmentKey.slice(0, 32);
fileMac = rawAttachmentKey.slice(32, 64);
}
} catch {
// fallback to item key
}
}
const plainBytes = await decryptBwFileData(encryptedBytes, fileEnc, fileMac);
const fileNameRaw = String(info.fileName || attachment?.fileName || '').trim();
let fileName = fileNameRaw || `attachment-${attachmentId}`;
if (fileNameRaw && looksLikeCipherString(fileNameRaw)) {
try {
fileName = (await decryptStr(fileNameRaw, itemEnc, itemMac)) || fileName;
} catch {
// fallback to raw encrypted name
}
}
out.push({
cipherId,
fileName,
bytes: plainBytes,
});
}
}
return out;
};
const getNodeWardenAttachmentRecords = async () => {
if (nodeWardenAttachmentsCache) return nodeWardenAttachmentsCache;
const [doc, attachments] = await Promise.all([getPlainJsonDoc(), zipAttachments()]);
const cipherIndexById = new Map<string, number>();
const items = Array.isArray(doc.items) ? (doc.items as Array<Record<string, unknown>>) : [];
for (let i = 0; i < items.length; i++) {
const id = String(items[i]?.id || '').trim();
if (id) cipherIndexById.set(id, i);
}
nodeWardenAttachmentsCache = buildNodeWardenAttachmentRecords(attachments, cipherIndexById);
return nodeWardenAttachmentsCache;
};
const format = request.format;
if (format === 'bitwarden_json') {
const bytes = new TextEncoder().encode(await getPlainJson());
return {
fileName: buildExportFileName(format),
mimeType: 'application/json',
bytes,
};
}
if (format === 'bitwarden_encrypted_json') {
if (request.encryptedJsonMode === 'password') {
const plainJson = await getPlainJson();
const kdf = await getPreloginKdfConfig(profile?.email || session.email, defaultKdfIterations);
const encrypted = await buildPasswordProtectedBitwardenJsonString({
plaintextJson: plainJson,
password: String(request.filePassword || ''),
kdf,
});
return {
fileName: buildExportFileName(format),
mimeType: 'application/json',
bytes: new TextEncoder().encode(encrypted),
};
}
const bytes = new TextEncoder().encode(await getEncryptedJson());
return {
fileName: buildExportFileName(format),
mimeType: 'application/json',
bytes,
};
}
if (format === 'nodewarden_json') {
const [plainDoc, attachments] = await Promise.all([getPlainJsonDoc(), getNodeWardenAttachmentRecords()]);
const nodeWardenDoc = buildNodeWardenPlainJsonDocument(plainDoc, attachments);
return {
fileName: buildExportFileName(format),
mimeType: 'application/json',
bytes: new TextEncoder().encode(JSON.stringify(nodeWardenDoc, null, 2)),
};
}
if (format === 'nodewarden_encrypted_json') {
if (request.encryptedJsonMode === 'password') {
const [plainDoc, attachments] = await Promise.all([getPlainJsonDoc(), getNodeWardenAttachmentRecords()]);
const nodeWardenDoc = buildNodeWardenPlainJsonDocument(plainDoc, attachments);
const kdf = await getPreloginKdfConfig(profile?.email || session.email, defaultKdfIterations);
const encrypted = await buildPasswordProtectedBitwardenJsonString({
plaintextJson: JSON.stringify(nodeWardenDoc, null, 2),
password: String(request.filePassword || ''),
kdf,
});
return {
fileName: buildExportFileName(format),
mimeType: 'application/json',
bytes: new TextEncoder().encode(encrypted),
};
}
const [encryptedJson, attachments] = await Promise.all([getEncryptedJson(), getNodeWardenAttachmentRecords()]);
const withAttachments = await attachNodeWardenEncryptedAttachmentPayload(
encryptedJson,
attachments,
session.symEncKey!,
session.symMacKey!
);
return {
fileName: buildExportFileName(format),
mimeType: 'application/json',
bytes: new TextEncoder().encode(withAttachments),
};
}
if (format === 'bitwarden_json_zip' || format === 'bitwarden_encrypted_json_zip') {
let dataJson = await getPlainJson();
if (format === 'bitwarden_encrypted_json_zip') {
if (request.encryptedJsonMode === 'password') {
const kdf = await getPreloginKdfConfig(profile?.email || session.email, defaultKdfIterations);
dataJson = await buildPasswordProtectedBitwardenJsonString({
plaintextJson: await getPlainJson(),
password: String(request.filePassword || ''),
kdf,
});
} else {
dataJson = await getEncryptedJson();
}
}
const attachments = await zipAttachments();
const zipBytes = buildBitwardenZipBytes(dataJson, attachments);
const encryptedZip = await encryptZipBytesWithPassword(zipBytes, String(request.zipPassword || ''));
return {
fileName: buildExportFileName(format, encryptedZip.encrypted),
mimeType: 'application/zip',
bytes: encryptedZip.bytes,
};
}
throw new Error('Unsupported export format');
}
const hashPathRaw = typeof window !== 'undefined' ? window.location.hash || '' : '';
const hashPath = hashPathRaw.startsWith('#') ? hashPathRaw.slice(1) : hashPathRaw;
const hashPathOnly = String(hashPath || '').split('?')[0].split('#')[0];
const normalizedHashPath = `/${hashPathOnly.replace(/^\/+/, '').replace(/\/+$/, '')}`.replace(/^\/$/, '/');
const isImportHashRoute = IMPORT_ROUTE_ALIASES.has(normalizedHashPath);
const effectiveLocation = hashPath.startsWith('/send/') || hashPath === '/recover-2fa' ? hashPath : location;
const publicSendMatch = effectiveLocation.match(/^\/send\/([^/]+)(?:\/([^/]+))?\/?$/i);
const isRecoverTwoFactorRoute = effectiveLocation === '/recover-2fa';
const isPublicSendRoute = !!publicSendMatch;
const isImportRoute = location === IMPORT_ROUTE || IMPORT_ROUTE_ALIASES.has(location);
useEffect(() => {
if (phase === 'app' && location === '/' && !isPublicSendRoute) navigate('/vault');
}, [phase, location, isPublicSendRoute, navigate]);
useEffect(() => {
if (phase === 'app' && isImportHashRoute && location !== IMPORT_ROUTE) {
navigate(IMPORT_ROUTE);
}
}, [phase, isImportHashRoute, location, navigate]);
if (jwtWarning) {
return <JwtWarningPage reason={jwtWarning.reason} minLength={jwtWarning.minLength} />;
}
@@ -982,7 +1691,7 @@ export default function App() {
<Cloud size={16} />
<span>{t('nav_backup_strategy')}</span>
</Link>
<Link href="/help/import-export" className={`side-link ${location === '/help/import-export' ? 'active' : ''}`}>
<Link href={IMPORT_ROUTE} className={`side-link ${isImportRoute ? 'active' : ''}`}>
<ArrowUpDown size={14} />
<span>{t('nav_import_export')}</span>
</Link>
@@ -1016,6 +1725,8 @@ export default function App() {
onVerifyMasterPassword={verifyMasterPasswordAction}
onNotify={pushToast}
onCreateFolder={createFolderAction}
onDeleteFolder={deleteFolderAction}
onDownloadAttachment={downloadVaultAttachment}
/>
</Route>
<Route path="/settings">
@@ -1130,8 +1841,65 @@ export default function App() {
}}
/>
</Route>
<Route path="/help/import-export">
<ImportExportPage />
<Route path={IMPORT_ROUTE}>
<ImportPage
onImport={handleImportAction}
onImportEncryptedRaw={handleImportEncryptedRawAction}
accountKeys={session?.symEncKey && session?.symMacKey ? { encB64: session.symEncKey, macB64: session.symMacKey } : null}
onNotify={pushToast}
folders={decryptedFolders}
onExport={handleExportAction}
/>
</Route>
<Route path="/tools/import">
<ImportPage
onImport={handleImportAction}
onImportEncryptedRaw={handleImportEncryptedRawAction}
accountKeys={session?.symEncKey && session?.symMacKey ? { encB64: session.symEncKey, macB64: session.symMacKey } : null}
onNotify={pushToast}
folders={decryptedFolders}
onExport={handleExportAction}
/>
</Route>
<Route path="/tools/import-export">
<ImportPage
onImport={handleImportAction}
onImportEncryptedRaw={handleImportEncryptedRawAction}
accountKeys={session?.symEncKey && session?.symMacKey ? { encB64: session.symEncKey, macB64: session.symMacKey } : null}
onNotify={pushToast}
folders={decryptedFolders}
onExport={handleExportAction}
/>
</Route>
<Route path="/tools/import-data">
<ImportPage
onImport={handleImportAction}
onImportEncryptedRaw={handleImportEncryptedRawAction}
accountKeys={session?.symEncKey && session?.symMacKey ? { encB64: session.symEncKey, macB64: session.symMacKey } : null}
onNotify={pushToast}
folders={decryptedFolders}
onExport={handleExportAction}
/>
</Route>
<Route path="/import">
<ImportPage
onImport={handleImportAction}
onImportEncryptedRaw={handleImportEncryptedRawAction}
accountKeys={session?.symEncKey && session?.symMacKey ? { encB64: session.symEncKey, macB64: session.symMacKey } : null}
onNotify={pushToast}
folders={decryptedFolders}
onExport={handleExportAction}
/>
</Route>
<Route path="/import-export">
<ImportPage
onImport={handleImportAction}
onImportEncryptedRaw={handleImportEncryptedRawAction}
accountKeys={session?.symEncKey && session?.symMacKey ? { encB64: session.symEncKey, macB64: session.symMacKey } : null}
onNotify={pushToast}
folders={decryptedFolders}
onExport={handleExportAction}
/>
</Route>
<Route path="/help">
<HelpPage />
+3
View File
@@ -1,4 +1,5 @@
import type { ComponentChildren } from 'preact';
import { Check, X } from 'lucide-preact';
import { t } from '@/lib/i18n';
interface ConfirmDialogProps {
@@ -28,9 +29,11 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
className={`btn ${props.danger ? 'btn-danger' : 'btn-primary'} dialog-btn`}
onClick={props.onConfirm}
>
<Check size={14} className="btn-icon" />
{props.confirmText || t('txt_yes')}
</button>
<button type="button" className="btn btn-secondary dialog-btn" onClick={props.onCancel}>
<X size={14} className="btn-icon" />
{props.cancelText || t('txt_no')}
</button>
{props.afterActions}
@@ -1,19 +0,0 @@
import { ArrowUpDown } from 'lucide-preact';
import { t } from '@/lib/i18n';
export default function ImportExportPage() {
return (
<div className="stack">
<section className="card">
<h3>{t('import_export_title')}</h3>
<div className="empty" style={{ minHeight: 180 }}>
<div style={{ textAlign: 'center' }}>
<ArrowUpDown size={34} style={{ color: '#64748b', marginBottom: 8 }} />
<div>{t('import_export_under_construction')}</div>
</div>
</div>
</section>
</div>
);
}
+895
View File
@@ -0,0 +1,895 @@
import { useState } from 'preact/hooks';
import { argon2idAsync } from '@noble/hashes/argon2.js';
import { strFromU8, unzipSync } from 'fflate';
import { BlobReader, Uint8ArrayWriter, ZipReader, configure as configureZipJs } from '@zip.js/zip.js';
import { Archive, ArrowLeftRight, Download, FileJson, FileUp } from 'lucide-preact';
import ConfirmDialog from '@/components/ConfirmDialog';
import type { CiphersImportPayload } from '@/lib/api';
import {
type EncryptedJsonMode,
EXPORT_FORMATS,
type ExportDownloadPayload,
type ExportFormatId,
type ExportRequest,
} from '@/lib/export-formats';
import {
getFileAcceptBySource,
IMPORT_SOURCES,
type BitwardenJsonInput,
type ImportSourceId,
normalizeBitwardenEncryptedAccountImport,
normalizeBitwardenImport,
parseImportPayloadBySource,
} from '@/lib/import-formats';
import { base64ToBytes, decryptStr, hkdfExpand, pbkdf2 } from '@/lib/crypto';
import { t } from '@/lib/i18n';
import type { Folder } from '@/lib/types';
configureZipJs({ useWebWorkers: false });
export interface ImportAttachmentFile {
sourceCipherId: string | null;
sourceCipherIndex: number | null;
fileName: string;
bytes: Uint8Array;
}
interface ImportPageProps {
onImport: (
payload: CiphersImportPayload,
options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null },
attachments?: ImportAttachmentFile[]
) => Promise<ImportResultSummary>;
onImportEncryptedRaw: (
payload: CiphersImportPayload,
options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null },
attachments?: ImportAttachmentFile[]
) => Promise<ImportResultSummary>;
accountKeys?: { encB64: string; macB64: string } | null;
onNotify: (type: 'success' | 'error', text: string) => void;
folders: Folder[];
onExport: (request: ExportRequest) => Promise<ExportDownloadPayload>;
}
export interface ImportResultSummary {
totalItems: number;
folderCount: number;
typeCounts: Array<{ label: string; count: number }>;
}
interface BitwardenPasswordProtectedInput extends BitwardenJsonInput {
encrypted: true;
passwordProtected: true;
salt?: string;
kdfIterations?: number;
kdfMemory?: number;
kdfParallelism?: number;
kdfType?: number;
data?: string;
}
const COMMON_IMPORT_SOURCE_IDS: ImportSourceId[] = [
'bitwarden_json',
'bitwarden_csv',
'bitwarden_zip',
'nodewarden_json',
'onepassword_1pux',
'onepassword_1pif',
'onepassword_mac_csv',
'onepassword_win_csv',
'protonpass_json',
'chrome',
'edge',
'brave',
'opera',
'vivaldi',
'firefox_csv',
'safari_csv',
'lastpass',
'dashlane_csv',
'dashlane_json',
'keepass_xml',
'keepassx_csv',
];
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object';
}
function isPasswordProtectedExport(value: unknown): value is BitwardenPasswordProtectedInput {
return isRecord(value) && value.encrypted === true && value.passwordProtected === true;
}
async function derivePasswordProtectedFileKey(
parsed: BitwardenPasswordProtectedInput,
password: string
): Promise<{ enc: Uint8Array; mac: Uint8Array }> {
const salt = String(parsed.salt || '').trim();
const iterations = Number(parsed.kdfIterations || 0);
const kdfType = Number(parsed.kdfType);
if (!salt || !Number.isFinite(iterations) || iterations <= 0) {
throw new Error(t('txt_import_invalid_password_protected_file'));
}
let keyMaterial: Uint8Array;
if (kdfType === 0) {
keyMaterial = await pbkdf2(password, salt, iterations, 32);
} else if (kdfType === 1) {
const memoryMiB = Number(parsed.kdfMemory || 0);
const parallelism = Number(parsed.kdfParallelism || 0);
if (!Number.isFinite(memoryMiB) || memoryMiB <= 0 || !Number.isFinite(parallelism) || parallelism <= 0) {
throw new Error('Invalid Argon2id parameters in export file.');
}
const memoryKiB = Math.floor(memoryMiB * 1024);
const maxmem = memoryKiB * 1024 + 1024 * 1024;
keyMaterial = await argon2idAsync(new TextEncoder().encode(password), new TextEncoder().encode(salt), {
t: Math.floor(iterations),
m: memoryKiB,
p: Math.floor(parallelism),
dkLen: 32,
maxmem,
asyncTick: 10,
});
} else {
throw new Error(`Unsupported kdfType: ${kdfType}`);
}
const enc = await hkdfExpand(keyMaterial, 'enc', 32);
const mac = await hkdfExpand(keyMaterial, 'mac', 32);
return { enc, mac };
}
async function decryptPasswordProtectedExport(parsed: BitwardenPasswordProtectedInput, password: string): Promise<unknown> {
if (!parsed.encKeyValidation_DO_NOT_EDIT || !parsed.data) {
throw new Error(t('txt_import_invalid_password_protected_file'));
}
const pass = String(password || '').trim();
if (!pass) {
throw new Error(t('txt_import_file_password_required'));
}
const key = await derivePasswordProtectedFileKey(parsed, pass);
try {
await decryptStr(parsed.encKeyValidation_DO_NOT_EDIT, key.enc, key.mac);
} catch {
throw new Error('Invalid file password.');
}
const plainJson = await decryptStr(parsed.data, key.enc, key.mac);
try {
return JSON.parse(plainJson);
} catch {
throw new Error(t('txt_import_decrypt_failed'));
}
}
function isZipPayload(bytes: Uint8Array): boolean {
return bytes.length >= 4 && bytes[0] === 0x50 && bytes[1] === 0x4b && bytes[2] === 0x03 && bytes[3] === 0x04;
}
function readZipText(bytes: Uint8Array, source: ImportSourceId): string {
const unzipped = unzipSync(bytes);
const fileNames = Object.keys(unzipped);
if (!fileNames.length) throw new Error(t('txt_import_empty_zip_archive'));
const preferred = source === 'onepassword_1pux' ? ['export.data', 'export.json'] : ['protonpass.json', 'export.json'];
for (const p of preferred) {
const hit = fileNames.find((n) => n.toLowerCase().endsWith(p.toLowerCase()));
if (hit) return strFromU8(unzipped[hit]);
}
const firstJson = fileNames.find((n) => n.toLowerCase().endsWith('.json') || n.toLowerCase().endsWith('.data'));
if (firstJson) return strFromU8(unzipped[firstJson]);
throw new Error(t('txt_import_no_json_found_in_zip'));
}
async function readImportText(file: File, source: ImportSourceId): Promise<string> {
if (source !== 'onepassword_1pux' && source !== 'protonpass_json') {
return file.text();
}
const bytes = new Uint8Array(await file.arrayBuffer());
if (isZipPayload(bytes)) return readZipText(bytes, source);
return new TextDecoder().decode(bytes);
}
interface PendingPasswordImportContext {
parsed: BitwardenPasswordProtectedInput;
source: 'bitwarden_json' | 'nodewarden_json' | 'bitwarden_zip';
attachments: ImportAttachmentFile[];
}
class ZipNeedsPasswordError extends Error {}
class ZipInvalidPasswordError extends Error {}
function looksLikeZipPasswordError(error: unknown): boolean {
const message = error instanceof Error ? String(error.message || '').toLowerCase() : '';
if (!message) return false;
return message.includes('password') || message.includes('encrypted');
}
async function readBitwardenZipPayload(
file: File,
passwordRaw: string
): Promise<{ jsonText: string; attachments: ImportAttachmentFile[] }> {
const password = String(passwordRaw || '').trim();
const reader = new ZipReader(new BlobReader(file), { useWebWorkers: false });
try {
const entries = await reader.getEntries();
if (!entries.length) throw new Error(t('txt_import_empty_zip_archive'));
let jsonText = '';
const attachments: ImportAttachmentFile[] = [];
const options = password ? { password } : undefined;
for (const entry of entries) {
if (entry.directory) continue;
const name = String(entry.filename || '').trim().replace(/\\/g, '/');
if (!name) continue;
const bytes = await entry.getData(new Uint8ArrayWriter(), options);
const lower = name.toLowerCase();
if (lower === 'data.json') {
jsonText = new TextDecoder().decode(bytes);
continue;
}
const attachmentMatch = name.match(/^attachments\/([^/]+)\/(.+)$/i);
if (!attachmentMatch) continue;
const sourceCipherId = String(attachmentMatch[1] || '').trim() || null;
const fileName = String(attachmentMatch[2] || '').trim() || 'attachment.bin';
attachments.push({
sourceCipherId,
sourceCipherIndex: null,
fileName,
bytes,
});
}
if (!jsonText) throw new Error(t('txt_import_data_json_not_found'));
return { jsonText, attachments };
} catch (error) {
if (looksLikeZipPasswordError(error)) {
if (!password) throw new ZipNeedsPasswordError(t('txt_import_zip_password_required'));
throw new ZipInvalidPasswordError(t('txt_import_invalid_zip_password'));
}
if (!password && error instanceof Error && /invalid|corrupt|unsupported/.test(error.message.toLowerCase())) {
throw error;
}
throw error;
} finally {
await reader.close();
}
}
function parseNodeWardenAttachmentArray(raw: unknown): ImportAttachmentFile[] {
if (!Array.isArray(raw)) return [];
const out: ImportAttachmentFile[] = [];
for (const entry of raw) {
if (!entry || typeof entry !== 'object') continue;
const row = entry as Record<string, unknown>;
const fileName = String(row.fileName || '').trim() || 'attachment.bin';
const base64 = String(row.data || '').trim();
if (!base64) continue;
try {
const bytes = base64ToBytes(base64);
const sourceCipherId = String(row.cipherId || '').trim() || null;
const indexRaw = Number(row.cipherIndex);
out.push({
sourceCipherId,
sourceCipherIndex: Number.isFinite(indexRaw) ? indexRaw : null,
fileName,
bytes,
});
} catch {
// skip malformed attachment row
}
}
return out;
}
export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys, onNotify, folders, onExport }: ImportPageProps) {
const [source, setSource] = useState<ImportSourceId>('bitwarden_json');
const [file, setFile] = useState<File | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isPasswordSubmitting, setIsPasswordSubmitting] = useState(false);
const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
const [importPassword, setImportPassword] = useState('');
const [pendingPasswordImport, setPendingPasswordImport] = useState<PendingPasswordImportContext | null>(null);
const [zipPasswordDialogOpen, setZipPasswordDialogOpen] = useState(false);
const [zipImportPassword, setZipImportPassword] = useState('');
const [pendingZipFile, setPendingZipFile] = useState<File | null>(null);
const [isZipPasswordSubmitting, setIsZipPasswordSubmitting] = useState(false);
const [folderMode, setFolderMode] = useState<'original' | 'none' | 'target'>('original');
const [targetFolderId, setTargetFolderId] = useState('');
const [exportFormat, setExportFormat] = useState<ExportFormatId>('bitwarden_json');
const [encryptedJsonMode, setEncryptedJsonMode] = useState<EncryptedJsonMode>('account');
const [exportPassword, setExportPassword] = useState('');
const [zipPassword, setZipPassword] = useState('');
const [isExporting, setIsExporting] = useState(false);
const [exportAuthDialogOpen, setExportAuthDialogOpen] = useState(false);
const [exportAuthPassword, setExportAuthPassword] = useState('');
const [importSummary, setImportSummary] = useState<ImportResultSummary | null>(null);
const commonSourceSet = new Set<ImportSourceId>(COMMON_IMPORT_SOURCE_IDS);
const commonSources = IMPORT_SOURCES.filter((item) => commonSourceSet.has(item.id as ImportSourceId));
const otherSources = IMPORT_SOURCES.filter((item) => !commonSourceSet.has(item.id as ImportSourceId));
async function runBitwardenJsonImport(parsed: unknown, attachments: ImportAttachmentFile[] = []): Promise<ImportResultSummary> {
if (isRecord(parsed) && parsed.encrypted === true) {
const accountEncrypted = parsed as BitwardenJsonInput;
if (!accountKeys?.encB64 || !accountKeys?.macB64) {
throw new Error('Vault key unavailable. Please unlock vault and try again.');
}
const validation = String(accountEncrypted.encKeyValidation_DO_NOT_EDIT || '').trim();
if (!validation) throw new Error('Invalid encrypted export file.');
const accountEncKey = base64ToBytes(accountKeys.encB64);
const accountMacKey = base64ToBytes(accountKeys.macB64);
try {
await decryptStr(validation, accountEncKey, accountMacKey);
} catch {
throw new Error('This encrypted export belongs to another account.');
}
return onImportEncryptedRaw(
normalizeBitwardenEncryptedAccountImport(accountEncrypted),
{
folderMode,
targetFolderId: folderMode === 'target' ? targetFolderId || null : null,
},
attachments
);
}
return onImport(
normalizeBitwardenImport(parsed),
{
folderMode,
targetFolderId: folderMode === 'target' ? targetFolderId || null : null,
},
attachments
);
}
async function extractNodeWardenAttachments(parsed: unknown): Promise<ImportAttachmentFile[]> {
if (!isRecord(parsed)) return [];
const direct = parseNodeWardenAttachmentArray(parsed.nodewardenAttachments);
if (direct.length) return direct;
const encryptedPayload = String(parsed.nodewardenAttachmentsEnc || '').trim();
if (!encryptedPayload) return [];
if (!accountKeys?.encB64 || !accountKeys?.macB64) {
throw new Error('Vault key unavailable. Please unlock vault and try again.');
}
const accountEnc = base64ToBytes(accountKeys.encB64);
const accountMac = base64ToBytes(accountKeys.macB64);
const plain = await decryptStr(encryptedPayload, accountEnc, accountMac);
const unpacked = JSON.parse(plain) as Record<string, unknown>;
return parseNodeWardenAttachmentArray(unpacked.nodewardenAttachments);
}
async function runNodeWardenJsonImport(parsed: unknown, extraAttachments: ImportAttachmentFile[] = []): Promise<ImportResultSummary> {
const bundled = await extractNodeWardenAttachments(parsed);
return runBitwardenJsonImport(parsed, [...bundled, ...extraAttachments]);
}
async function processPasswordProtectedImport(ctx: PendingPasswordImportContext): Promise<ImportResultSummary> {
const parsed = await decryptPasswordProtectedExport(ctx.parsed, importPassword);
if (ctx.source === 'nodewarden_json') {
return runNodeWardenJsonImport(parsed, ctx.attachments);
}
return runBitwardenJsonImport(parsed, ctx.attachments);
}
async function handleSubmit() {
if (!file) {
onNotify('error', t('txt_please_select_a_file'));
return;
}
setIsSubmitting(true);
try {
if (source === 'bitwarden_zip') {
try {
const bundle = await readBitwardenZipPayload(file, '');
let parsed: unknown;
try {
parsed = JSON.parse(bundle.jsonText);
} catch {
throw new Error(t('txt_import_invalid_json_file'));
}
if (isPasswordProtectedExport(parsed)) {
setPendingPasswordImport({
parsed,
source: 'bitwarden_zip',
attachments: bundle.attachments,
});
setImportPassword('');
setPasswordDialogOpen(true);
return;
}
const summary = await runBitwardenJsonImport(parsed, bundle.attachments);
setImportSummary(summary);
setFile(null);
return;
} catch (error) {
if (error instanceof ZipNeedsPasswordError) {
setPendingZipFile(file);
setZipImportPassword('');
setZipPasswordDialogOpen(true);
return;
}
throw error;
}
}
const text = await readImportText(file, source);
if (source === 'bitwarden_json' || source === 'nodewarden_json') {
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
throw new Error(t('txt_import_invalid_json_file'));
}
if (isPasswordProtectedExport(parsed)) {
setPendingPasswordImport({
parsed,
source,
attachments: [],
});
setImportPassword('');
setPasswordDialogOpen(true);
return;
}
const summary =
source === 'nodewarden_json'
? await runNodeWardenJsonImport(parsed)
: await runBitwardenJsonImport(parsed);
setImportSummary(summary);
} else {
const summary = await onImport(
parseImportPayloadBySource(source, text),
{
folderMode,
targetFolderId: folderMode === 'target' ? targetFolderId || null : null,
},
[]
);
setImportSummary(summary);
}
setFile(null);
} catch (error) {
const message = error instanceof Error ? error.message : t('txt_import_failed');
onNotify('error', message);
} finally {
setIsSubmitting(false);
}
}
async function handlePasswordImportConfirm() {
if (!pendingPasswordImport) return;
setIsPasswordSubmitting(true);
try {
const summary = await processPasswordProtectedImport(pendingPasswordImport);
setImportSummary(summary);
setFile(null);
setImportPassword('');
setPendingPasswordImport(null);
setPasswordDialogOpen(false);
} catch (error) {
const message = error instanceof Error ? error.message : t('txt_import_failed');
onNotify('error', message);
} finally {
setIsPasswordSubmitting(false);
}
}
async function handleZipPasswordImportConfirm() {
if (!pendingZipFile) return;
setIsZipPasswordSubmitting(true);
try {
const bundle = await readBitwardenZipPayload(pendingZipFile, zipImportPassword);
let parsed: unknown;
try {
parsed = JSON.parse(bundle.jsonText);
} catch {
throw new Error(t('txt_import_invalid_json_file'));
}
if (isPasswordProtectedExport(parsed)) {
setPendingPasswordImport({
parsed,
source: 'bitwarden_zip',
attachments: bundle.attachments,
});
setImportPassword('');
setPasswordDialogOpen(true);
} else {
const summary = await runBitwardenJsonImport(parsed, bundle.attachments);
setImportSummary(summary);
setFile(null);
}
setZipPasswordDialogOpen(false);
setPendingZipFile(null);
setZipImportPassword('');
} catch (error) {
if (error instanceof ZipInvalidPasswordError) {
onNotify('error', t('txt_import_invalid_zip_password'));
return;
}
const message = error instanceof Error ? error.message : t('txt_import_failed');
onNotify('error', message);
} finally {
setIsZipPasswordSubmitting(false);
}
}
const exportNeedsMode =
exportFormat === 'bitwarden_encrypted_json' ||
exportFormat === 'bitwarden_encrypted_json_zip' ||
exportFormat === 'nodewarden_encrypted_json';
const exportNeedsFilePassword = exportNeedsMode && encryptedJsonMode === 'password';
const exportIsZip = exportFormat === 'bitwarden_json_zip' || exportFormat === 'bitwarden_encrypted_json_zip';
async function runExportWithMasterPassword(masterPassword: string) {
const filePassword = exportPassword.trim();
const zipPass = zipPassword.trim();
if (exportNeedsFilePassword && !filePassword) {
onNotify('error', t('txt_import_file_password_required'));
return;
}
setIsExporting(true);
try {
const payload = await onExport({
format: exportFormat,
encryptedJsonMode: exportNeedsMode ? encryptedJsonMode : undefined,
filePassword,
zipPassword: exportIsZip ? zipPass : '',
masterPassword,
});
const blobBytes = Uint8Array.from(payload.bytes);
const blob = new Blob([blobBytes], { type: payload.mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = payload.fileName;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
onNotify('success', t('txt_export_completed'));
} catch (error) {
const message = error instanceof Error ? error.message : t('txt_export_failed');
onNotify('error', message);
} finally {
setIsExporting(false);
}
}
async function handleExportConfirmPassword() {
const masterPassword = String(exportAuthPassword || '').trim();
if (!masterPassword) {
onNotify('error', t('txt_master_password_is_required'));
return;
}
await runExportWithMasterPassword(masterPassword);
if (!isExporting) {
setExportAuthPassword('');
setExportAuthDialogOpen(false);
}
}
function handleExport() {
setExportAuthPassword('');
setExportAuthDialogOpen(true);
}
return (
<div className="import-export-page">
<section className="card import-export-hero">
<h3>{t('txt_import_export_title')}</h3>
<p className="import-export-hero-sub">{t('txt_import_export_feature_intro')}</p>
<div className="import-export-feature-grid">
<article className="import-export-feature-item">
<span className="import-export-feature-icon">
<Archive size={16} />
</span>
<div>
<strong>{t('txt_import_export_feature_bw_zip_title')}</strong>
<p>{t('txt_import_export_feature_bw_zip_desc')}</p>
</div>
</article>
<article className="import-export-feature-item">
<span className="import-export-feature-icon">
<FileJson size={16} />
</span>
<div>
<strong>{t('txt_import_export_feature_nodewarden_json_title')}</strong>
<p>{t('txt_import_export_feature_nodewarden_json_desc')}</p>
</div>
</article>
<article className="import-export-feature-item">
<span className="import-export-feature-icon">
<ArrowLeftRight size={16} />
</span>
<div>
<strong>{t('txt_import_export_feature_compat_title')}</strong>
<p>{t('txt_import_export_feature_compat_desc')}</p>
</div>
</article>
</div>
</section>
<div className="import-export-panels">
<section className="card import-export-panel">
<h3>{t('txt_import')}</h3>
<p className="muted" style={{ textAlign: 'left', marginBottom: 12 }}>
{t('txt_import_vault_data_hint')}
</p>
<div className="field-grid">
<label className="field field-span-2">
<span>{t('txt_format')}</span>
<select className="input" value={source} onChange={(e) => setSource((e.currentTarget as HTMLSelectElement).value as ImportSourceId)}>
{commonSources.map((item) => (
<option key={item.id} value={item.id}>
{item.label}
</option>
))}
{otherSources.length > 0 && (
<option disabled value="__separator__">
--------------------
</option>
)}
{otherSources.map((item) => (
<option key={item.id} value={item.id}>
{item.label}
</option>
))}
</select>
</label>
<label className="field field-span-2">
<span>{t('txt_source_file')}</span>
<input
className="input"
type="file"
accept={getFileAcceptBySource(source)}
onChange={(e) => {
const next = (e.currentTarget as HTMLInputElement).files?.[0] || null;
setFile(next);
}}
/>
</label>
<label className="field field-span-2">
<span>{t('txt_folder_handling')}</span>
<select
className="input"
value={folderMode}
onChange={(e) => setFolderMode((e.currentTarget as HTMLSelectElement).value as 'original' | 'none' | 'target')}
>
<option value="original">{t('txt_import_folder_mode_original')}</option>
<option value="none">{t('txt_import_folder_mode_none')}</option>
<option value="target">{t('txt_import_folder_mode_target')}</option>
</select>
</label>
{folderMode === 'target' && (
<label className="field field-span-2">
<span>{t('txt_target_folder')}</span>
<select className="input" value={targetFolderId} onChange={(e) => setTargetFolderId((e.currentTarget as HTMLSelectElement).value)}>
<option value="">{t('txt_select_folder_placeholder')}</option>
{folders
.slice()
.sort((a, b) => String(a.decName || a.name || '').localeCompare(String(b.decName || b.name || '')))
.map((folder) => (
<option key={folder.id} value={folder.id}>
{folder.decName || folder.name || folder.id}
</option>
))}
</select>
</label>
)}
</div>
<div className="actions">
<button
type="button"
className="btn btn-primary"
disabled={isSubmitting || (folderMode === 'target' && !targetFolderId)}
onClick={() => void handleSubmit()}
>
<FileUp size={15} /> {isSubmitting ? t('txt_loading') : t('txt_import')}
</button>
</div>
</section>
<section className="card import-export-panel">
<h3>{t('txt_export')}</h3>
<p className="muted" style={{ textAlign: 'left', marginBottom: 12 }}>
{t('txt_export_vault_data_hint')}
</p>
<div className="field-grid">
<label className="field field-span-2">
<span>{t('txt_format')}</span>
<select
className="input"
value={exportFormat}
onChange={(e) => {
const next = (e.currentTarget as HTMLSelectElement).value as ExportFormatId;
setExportFormat(next);
}}
>
{EXPORT_FORMATS.map((item) => (
<option key={item.id} value={item.id}>
{item.label}
</option>
))}
</select>
</label>
{exportNeedsMode && (
<label className="field field-span-2">
<span>{t('txt_encrypted_mode')}</span>
<select
className="input"
value={encryptedJsonMode}
onChange={(e) => setEncryptedJsonMode((e.currentTarget as HTMLSelectElement).value as EncryptedJsonMode)}
>
<option value="account">{t('txt_account_verification')}</option>
<option value="password">{t('txt_password_verification')}</option>
</select>
</label>
)}
{exportNeedsFilePassword && (
<label className="field field-span-2">
<span>{t('txt_file_password')}</span>
<input
className="input"
type="password"
value={exportPassword}
onInput={(e) => setExportPassword((e.currentTarget as HTMLInputElement).value)}
/>
</label>
)}
{exportIsZip && (
<label className="field field-span-2">
<span>{t('txt_zip_password_optional')}</span>
<input
className="input"
type="password"
value={zipPassword}
onInput={(e) => setZipPassword((e.currentTarget as HTMLInputElement).value)}
/>
</label>
)}
</div>
<div className="actions">
<button type="button" className="btn btn-primary" disabled={isExporting} onClick={() => void handleExport()}>
<Download size={15} className="btn-icon" />
{isExporting ? t('txt_loading') : t('txt_export')}
</button>
</div>
</section>
</div>
<ConfirmDialog
open={exportAuthDialogOpen}
title={t('txt_export')}
message={t('txt_enter_master_password_to_view_this_item')}
confirmText={isExporting ? t('txt_loading') : t('txt_verify')}
cancelText={t('txt_cancel')}
showIcon={false}
onConfirm={() => void handleExportConfirmPassword()}
onCancel={() => {
if (isExporting) return;
setExportAuthDialogOpen(false);
setExportAuthPassword('');
}}
>
<label className="field">
<span>{t('txt_master_password')}</span>
<input
className="input"
type="password"
value={exportAuthPassword}
onInput={(e) => setExportAuthPassword((e.currentTarget as HTMLInputElement).value)}
/>
</label>
</ConfirmDialog>
<ConfirmDialog
open={passwordDialogOpen}
title={t('txt_import_encrypted_file_title')}
message={t('txt_import_encrypted_file_message')}
confirmText={isPasswordSubmitting ? t('txt_loading') : t('txt_import')}
cancelText={t('txt_cancel')}
showIcon={false}
onConfirm={() => void handlePasswordImportConfirm()}
onCancel={() => {
if (isPasswordSubmitting) return;
setPasswordDialogOpen(false);
setImportPassword('');
setPendingPasswordImport(null);
}}
>
<label className="field">
<span>{t('txt_file_password')}</span>
<input
className="input"
type="password"
value={importPassword}
onInput={(e) => setImportPassword((e.currentTarget as HTMLInputElement).value)}
/>
</label>
</ConfirmDialog>
<ConfirmDialog
open={zipPasswordDialogOpen}
title={t('txt_import_encrypted_zip_title')}
message={t('txt_import_encrypted_zip_message')}
confirmText={isZipPasswordSubmitting ? t('txt_loading') : t('txt_import')}
cancelText={t('txt_cancel')}
showIcon={false}
onConfirm={() => void handleZipPasswordImportConfirm()}
onCancel={() => {
if (isZipPasswordSubmitting) return;
setZipPasswordDialogOpen(false);
setZipImportPassword('');
setPendingZipFile(null);
}}
>
<label className="field">
<span>{t('txt_zip_password')}</span>
<input
className="input"
type="password"
value={zipImportPassword}
onInput={(e) => setZipImportPassword((e.currentTarget as HTMLInputElement).value)}
/>
</label>
</ConfirmDialog>
{importSummary && (
<div className="dialog-mask">
<section className="dialog-card import-summary-dialog">
<button
type="button"
className="import-summary-close"
onClick={() => setImportSummary(null)}
aria-label={t('txt_close')}
>
X
</button>
<h3 className="dialog-title">{t('txt_import_success')}</h3>
<div className="dialog-message">{t('txt_import_success_number_of_items', { count: importSummary.totalItems })}</div>
<div className="import-summary-table-wrap">
<table className="import-summary-table">
<thead>
<tr>
<th>{t('txt_type')}</th>
<th>{t('txt_total')}</th>
</tr>
</thead>
<tbody>
{importSummary.typeCounts.map((row) => (
<tr key={row.label}>
<td>{row.label}</td>
<td>{row.count}</td>
</tr>
))}
<tr>
<td>{t('txt_folder')}</td>
<td>{importSummary.folderCount}</td>
</tr>
</tbody>
</table>
</div>
<button type="button" className="btn btn-primary dialog-btn" onClick={() => setImportSummary(null)}>
{t('txt_confirm')}
</button>
</section>
</div>
)}
</div>
);
}
+9 -3
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'preact/hooks';
import { Copy, Eye, EyeOff, File, FileText, LayoutGrid, Pencil, Plus, RefreshCw, Send as SendIcon, Trash2 } from 'lucide-preact';
import { CheckCheck, Copy, Eye, EyeOff, File, FileText, LayoutGrid, Pencil, Plus, RefreshCw, Save, Send as SendIcon, Trash2, X } from 'lucide-preact';
import type { Send, SendDraft } from '@/lib/types';
import { t } from '@/lib/i18n';
@@ -224,10 +224,12 @@ export default function SendsPage(props: SendsPageProps) {
setSelectedMap(map);
}}
>
<CheckCheck size={14} className="btn-icon" />
{t('txt_select_all')}
</button>
{!!selectedCount && (
<button type="button" className="btn btn-secondary small" onClick={() => setSelectedMap({})}>
<X size={14} className="btn-icon" />
{t('txt_cancel')}
</button>
)}
@@ -364,8 +366,12 @@ export default function SendsPage(props: SendsPageProps) {
</label>
</div>
<div className="detail-actions">
<button type="button" className="btn btn-primary small" disabled={busy} onClick={() => void saveDraft()}>{t('txt_save')}</button>
<button type="button" className="btn btn-secondary small" disabled={busy} onClick={() => { setIsEditing(false); setIsCreating(false); setDraft(null); setShowPassword(false); }}>{t('txt_cancel')}</button>
<button type="button" className="btn btn-primary small" disabled={busy} onClick={() => void saveDraft()}>
<Save size={14} className="btn-icon" /> {t('txt_save')}
</button>
<button type="button" className="btn btn-secondary small" disabled={busy} onClick={() => { setIsEditing(false); setIsCreating(false); setDraft(null); setShowPassword(false); }}>
<X size={14} className="btn-icon" /> {t('txt_cancel')}
</button>
</div>
</div>
)}
+9 -4
View File
@@ -55,10 +55,14 @@ export default function SettingsPage(props: SettingsPageProps) {
}, [props.profile.email, secret]);
async function enableTotp(): Promise<void> {
await props.onEnableTotp(secret, token);
// Secret is now stored on the server; remove plaintext copy from localStorage.
localStorage.removeItem(totpSecretStorageKey);
setTotpLocked(true);
try {
await props.onEnableTotp(secret, token);
// Secret is now stored on the server; remove plaintext copy from localStorage.
localStorage.removeItem(totpSecretStorageKey);
setTotpLocked(true);
} catch {
// Keep inputs editable after a failed attempt.
}
}
async function loadRecoveryCode(): Promise<void> {
@@ -178,6 +182,7 @@ export default function SettingsPage(props: SettingsPageProps) {
props.onNotify?.('success', t('txt_recovery_code_copied'));
}}
>
<Clipboard size={14} className="btn-icon" />
{t('txt_copy_code')}
</button>
</div>
+321 -19
View File
@@ -6,6 +6,7 @@ import {
CheckCheck,
Clipboard,
CreditCard,
Download,
Eye,
EyeOff,
ExternalLink,
@@ -17,6 +18,7 @@ import {
Globe,
KeyRound,
LayoutGrid,
Paperclip,
Pencil,
Plus,
RefreshCw,
@@ -25,9 +27,10 @@ import {
StarOff,
StickyNote,
Trash2,
Upload,
X,
} from 'lucide-preact';
import type { Cipher, CustomFieldType, Folder, VaultDraft, VaultDraftField } from '@/lib/types';
import type { Cipher, CipherAttachment, CustomFieldType, Folder, VaultDraft, VaultDraftField } from '@/lib/types';
import { t } from '@/lib/i18n';
interface VaultPageProps {
@@ -36,14 +39,16 @@ interface VaultPageProps {
loading: boolean;
emailForReprompt: string;
onRefresh: () => Promise<void>;
onCreate: (draft: VaultDraft) => Promise<void>;
onUpdate: (cipher: Cipher, draft: VaultDraft) => Promise<void>;
onCreate: (draft: VaultDraft, attachments?: File[]) => Promise<void>;
onUpdate: (cipher: Cipher, draft: VaultDraft, options?: { addFiles?: File[]; removeAttachmentIds?: string[] }) => Promise<void>;
onDelete: (cipher: Cipher) => Promise<void>;
onBulkDelete: (ids: string[]) => Promise<void>;
onBulkMove: (ids: string[], folderId: string | null) => Promise<void>;
onVerifyMasterPassword: (email: string, password: string) => Promise<void>;
onNotify: (type: 'success' | 'error', text: string) => void;
onCreateFolder: (name: string) => Promise<void>;
onDeleteFolder: (folderId: string) => Promise<void>;
onDownloadAttachment: (cipher: Cipher, attachmentId: string) => Promise<void>;
}
type TypeFilter = 'login' | 'card' | 'identity' | 'note' | 'ssh';
@@ -158,6 +163,7 @@ function createEmptyDraft(type: number): VaultDraft {
loginPassword: '',
loginTotp: '',
loginUris: [''],
loginFido2Credentials: [],
cardholderName: '',
cardNumber: '',
cardBrand: '',
@@ -203,6 +209,9 @@ function draftFromCipher(cipher: Cipher): VaultDraft {
draft.loginPassword = cipher.login.decPassword || '';
draft.loginTotp = cipher.login.decTotp || '';
draft.loginUris = (cipher.login.uris || []).map((x) => x.decUri || x.uri || '');
draft.loginFido2Credentials = Array.isArray(cipher.login.fido2Credentials)
? cipher.login.fido2Credentials.map((credential) => ({ ...credential }))
: [];
if (!draft.loginUris.length) draft.loginUris = [''];
}
if (cipher.card) {
@@ -264,6 +273,35 @@ function formatHistoryTime(value: string | null | undefined): string {
return date.toLocaleString();
}
function parseAttachmentSizeBytes(attachment: CipherAttachment): number {
const raw = attachment?.size;
if (typeof raw === 'number' && Number.isFinite(raw) && raw >= 0) return raw;
const parsed = Number(raw);
if (Number.isFinite(parsed) && parsed >= 0) return parsed;
return 0;
}
function formatAttachmentSize(attachment: CipherAttachment): string {
const sizeName = String(attachment?.sizeName || '').trim();
if (sizeName) return sizeName;
const bytes = parseAttachmentSizeBytes(attachment);
if (bytes <= 0) return '0 B';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
function firstPasskeyCreationTime(cipher: Cipher | null): string | null {
const credentials = cipher?.login?.fido2Credentials;
if (!Array.isArray(credentials) || credentials.length === 0) return null;
for (const credential of credentials) {
const raw = String(credential?.creationDate || '').trim();
if (raw) return raw;
}
return null;
}
const TOTP_PERIOD_SECONDS = 30;
const TOTP_RING_RADIUS = 14;
const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS;
@@ -325,13 +363,17 @@ export default function VaultPage(props: VaultPageProps) {
const [moveFolderId, setMoveFolderId] = useState('__none__');
const [createFolderOpen, setCreateFolderOpen] = useState(false);
const [newFolderName, setNewFolderName] = useState('');
const [pendingDeleteFolder, setPendingDeleteFolder] = useState<Folder | null>(null);
const [totpLive, setTotpLive] = useState<{ code: string; remain: number } | null>(null);
const [hiddenFieldVisibleMap, setHiddenFieldVisibleMap] = useState<Record<number, boolean>>({});
const [attachmentQueue, setAttachmentQueue] = useState<File[]>([]);
const [removedAttachmentIds, setRemovedAttachmentIds] = useState<Record<string, boolean>>({});
const [busy, setBusy] = useState(false);
const [repromptOpen, setRepromptOpen] = useState(false);
const [repromptPassword, setRepromptPassword] = useState('');
const [repromptApprovedCipherId, setRepromptApprovedCipherId] = useState<string | null>(null);
const createMenuRef = useRef<HTMLDivElement | null>(null);
const attachmentInputRef = useRef<HTMLInputElement | null>(null);
const sshSeedTicketRef = useRef(0);
const sshFingerprintTicketRef = useRef(0);
@@ -419,6 +461,20 @@ export default function VaultPage(props: VaultPageProps) {
() => props.ciphers.find((x) => x.id === selectedCipherId) || null,
[props.ciphers, selectedCipherId]
);
const passkeyCreatedAt = firstPasskeyCreationTime(selectedCipher);
const selectedAttachments = useMemo(
() => (Array.isArray(selectedCipher?.attachments) ? selectedCipher.attachments : []),
[selectedCipher]
);
const editExistingAttachments = useMemo(
() =>
selectedAttachments.filter((attachment) => {
const id = String(attachment?.id || '').trim();
return !!id;
}),
[selectedAttachments]
);
const removedAttachmentCount = useMemo(() => Object.values(removedAttachmentIds).filter(Boolean).length, [removedAttachmentIds]);
useEffect(() => {
const raw = selectedCipher?.login?.decTotp || '';
@@ -470,6 +526,8 @@ function folderName(id: string | null | undefined): string {
setSelectedCipherId('');
setShowPassword(false);
setLocalError('');
setAttachmentQueue([]);
setRemovedAttachmentIds({});
if (type === 5) void seedSshDefaults();
}
@@ -480,6 +538,8 @@ function folderName(id: string | null | undefined): string {
setIsEditing(true);
setShowPassword(false);
setLocalError('');
setAttachmentQueue([]);
setRemovedAttachmentIds({});
}
function cancelEdit(): void {
@@ -487,6 +547,8 @@ function folderName(id: string | null | undefined): string {
setIsEditing(false);
setIsCreating(false);
setLocalError('');
setAttachmentQueue([]);
setRemovedAttachmentIds({});
}
function updateDraft(patch: Partial<VaultDraft>): void {
@@ -555,6 +617,28 @@ function folderName(id: string | null | undefined): string {
});
}
function queueAttachmentFiles(list: FileList | null): void {
if (!list || !list.length) return;
const next = Array.from(list).filter((file) => file && file.size >= 0);
if (!next.length) return;
setAttachmentQueue((prev) => [...prev, ...next]);
}
function removeQueuedAttachment(index: number): void {
setAttachmentQueue((prev) => prev.filter((_, i) => i !== index));
}
function toggleExistingAttachmentRemoval(attachmentId: string): void {
const id = String(attachmentId || '').trim();
if (!id) return;
setRemovedAttachmentIds((prev) => {
const next = { ...prev };
if (next[id]) delete next[id];
else next[id] = true;
return next;
});
}
async function saveDraft(): Promise<void> {
if (!draft) return;
let nextDraft = draft;
@@ -572,14 +656,20 @@ function folderName(id: string | null | undefined): string {
setBusy(true);
try {
if (isCreating) {
await props.onCreate(nextDraft);
await props.onCreate(nextDraft, attachmentQueue);
} else if (selectedCipher) {
await props.onUpdate(selectedCipher, nextDraft);
const removeAttachmentIds = Object.keys(removedAttachmentIds).filter((id) => !!removedAttachmentIds[id]);
await props.onUpdate(selectedCipher, nextDraft, {
addFiles: attachmentQueue,
removeAttachmentIds,
});
}
setIsCreating(false);
setIsEditing(false);
setDraft(null);
setLocalError('');
setAttachmentQueue([]);
setRemovedAttachmentIds({});
} finally {
setBusy(false);
}
@@ -671,6 +761,20 @@ function folderName(id: string | null | undefined): string {
}
}
async function confirmDeleteFolder(): Promise<void> {
if (!pendingDeleteFolder) return;
setBusy(true);
try {
await props.onDeleteFolder(pendingDeleteFolder.id);
if (sidebarFilter.kind === 'folder' && sidebarFilter.folderId === pendingDeleteFolder.id) {
setSidebarFilter({ kind: 'all' });
}
setPendingDeleteFolder(null);
} finally {
setBusy(false);
}
}
return (
<>
<div className="vault-grid">
@@ -717,17 +821,32 @@ function folderName(id: string | null | undefined): string {
<FolderX size={14} className="tree-icon" /> <span className="tree-label">{t('txt_no_folder')}</span>
</button>
{props.folders.map((folder) => (
<button
key={folder.id}
type="button"
className={`tree-btn ${sidebarFilter.kind === 'folder' && sidebarFilter.folderId === folder.id ? 'active' : ''}`}
onClick={() => setSidebarFilter({ kind: 'folder', folderId: folder.id })}
>
<FolderIcon size={14} className="tree-icon" />
<span className="tree-label" title={folder.decName || folder.name || folder.id}>
{folder.decName || folder.name || folder.id}
</span>
</button>
<div key={folder.id} className="folder-row">
<button
type="button"
className={`tree-btn ${sidebarFilter.kind === 'folder' && sidebarFilter.folderId === folder.id ? 'active' : ''}`}
onClick={() => setSidebarFilter({ kind: 'folder', folderId: folder.id })}
>
<FolderIcon size={14} className="tree-icon" />
<span className="tree-label" title={folder.decName || folder.name || folder.id}>
{folder.decName || folder.name || folder.id}
</span>
</button>
<button
type="button"
className="folder-delete-btn"
title={t('txt_delete')}
aria-label={t('txt_delete')}
disabled={busy}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setPendingDeleteFolder(folder);
}}
>
<X size={12} />
</button>
</div>
))}
</div>
</aside>
@@ -818,6 +937,9 @@ function folderName(id: string | null | undefined): string {
type="button"
className="row-main"
onClick={() => {
if (isEditing || isCreating) {
cancelEdit();
}
setSelectedCipherId(cipher.id);
setRepromptApprovedCipherId(null);
}}
@@ -925,6 +1047,7 @@ function folderName(id: string | null | undefined): string {
className="btn btn-secondary small"
onClick={() => updateDraft({ loginUris: draft.loginUris.filter((_, i) => i !== index) })}
>
<X size={14} className="btn-icon" />
{t('txt_remove')}
</button>
)}
@@ -1013,6 +1136,104 @@ function folderName(id: string | null | undefined): string {
</div>
)}
<div className="card">
<div className="section-head attachment-head">
<h4>{t('txt_attachments')}</h4>
<button
type="button"
className="btn btn-secondary small attachment-add-btn"
disabled={busy}
onClick={() => attachmentInputRef.current?.click()}
title={t('txt_upload_attachments')}
aria-label={t('txt_upload_attachments')}
>
<Plus size={14} className="btn-icon" />
</button>
</div>
{!isCreating && selectedCipher && editExistingAttachments.length > 0 && (
<div className="attachment-list">
{editExistingAttachments.map((attachment) => {
const attachmentId = String(attachment?.id || '').trim();
if (!attachmentId) return null;
const removed = !!removedAttachmentIds[attachmentId];
const fileName = String(attachment.decFileName || attachment.fileName || attachmentId).trim() || attachmentId;
return (
<div key={`edit-attachment-${attachmentId}`} className={`attachment-row ${removed ? 'is-removed' : ''}`}>
<div className="attachment-main">
<Paperclip size={14} />
<div className="attachment-text">
<strong className="value-ellipsis" title={fileName}>{fileName}</strong>
<span>{formatAttachmentSize(attachment)}</span>
</div>
</div>
<div className="kv-actions">
<button
type="button"
className="btn btn-secondary small"
disabled={busy || removed}
onClick={() => void props.onDownloadAttachment(selectedCipher, attachmentId)}
>
<Download size={14} className="btn-icon" /> {t('txt_download')}
</button>
<button
type="button"
className="btn btn-secondary small"
disabled={busy}
onClick={() => toggleExistingAttachmentRemoval(attachmentId)}
>
<X size={14} className="btn-icon" />
{removed ? t('txt_cancel') : t('txt_remove')}
</button>
</div>
</div>
);
})}
</div>
)}
{!!removedAttachmentCount && (
<div className="detail-sub">{t('txt_marked_for_removal_count', { count: removedAttachmentCount })}</div>
)}
<input
ref={attachmentInputRef}
type="file"
className="attachment-file-input"
multiple
disabled={busy}
onChange={(e) => {
const input = e.currentTarget as HTMLInputElement;
queueAttachmentFiles(input.files);
input.value = '';
}}
/>
{!!attachmentQueue.length && (
<div className="attachment-list">
<div className="attachment-queue-title">{t('txt_new_attachments')}</div>
{attachmentQueue.map((file, index) => (
<div key={`queued-attachment-${index}-${file.name}`} className="attachment-row">
<div className="attachment-main">
<Upload size={14} />
<div className="attachment-text">
<strong className="value-ellipsis" title={file.name}>{file.name}</strong>
<span>{formatAttachmentSize({ size: file.size })}</span>
</div>
</div>
<div className="kv-actions">
<button
type="button"
className="btn btn-secondary small"
disabled={busy}
onClick={() => removeQueuedAttachment(index)}
>
<X size={14} className="btn-icon" />
{t('txt_remove')}
</button>
</div>
</div>
))}
</div>
)}
</div>
<div className="card">
<h4>{t('txt_additional_options')}</h4>
<label className="field">
@@ -1059,6 +1280,7 @@ function folderName(id: string | null | undefined): string {
className="btn btn-secondary small"
onClick={() => updateDraftCustomFields(draft.customFields.filter((_, i) => i !== originalIndex))}
>
<X size={14} className="btn-icon" />
{t('txt_remove')}
</button>
</div>
@@ -1068,14 +1290,17 @@ function folderName(id: string | null | undefined): string {
<div className="detail-actions">
<div className="actions">
<button type="button" className="btn btn-primary" disabled={busy} onClick={() => void saveDraft()}>
<CheckCheck size={14} className="btn-icon" />
{t('txt_confirm')}
</button>
<button type="button" className="btn btn-secondary" disabled={busy} onClick={cancelEdit}>
<X size={14} className="btn-icon" />
{t('txt_cancel')}
</button>
</div>
{!isCreating && selectedCipher && (
<button type="button" className="btn btn-danger" disabled={busy} onClick={() => setPendingDelete(selectedCipher)}>
<Trash2 size={14} className="btn-icon" />
{t('txt_delete')}
</button>
)}
@@ -1172,6 +1397,15 @@ function folderName(id: string | null | undefined): string {
</div>
</div>
)}
{!!passkeyCreatedAt && (
<div className="kv-row">
<span className="kv-label">{t('txt_passkey')}</span>
<div className="kv-main">
<strong>{t('txt_passkey_created_at_value', { value: formatHistoryTime(passkeyCreatedAt) })}</strong>
</div>
<div className="kv-actions" />
</div>
)}
</div>
)}
@@ -1227,9 +1461,33 @@ function folderName(id: string | null | undefined): string {
{selectedCipher.sshKey && (
<div className="card">
<h4>{t('txt_ssh_key')}</h4>
<div className="kv-line"><span>{t('txt_private_key')}</span><strong>{maskSecret(selectedCipher.sshKey.decPrivateKey || '')}</strong></div>
<div className="kv-line"><span>{t('txt_public_key')}</span><strong>{selectedCipher.sshKey.decPublicKey || ''}</strong></div>
<div className="kv-line"><span>{t('txt_fingerprint')}</span><strong>{selectedCipher.sshKey.decFingerprint || ''}</strong></div>
<div className="kv-row">
<span className="kv-label">{t('txt_private_key')}</span>
<div className="kv-main">
<strong className="value-ellipsis" title={maskSecret(selectedCipher.sshKey.decPrivateKey || '')}>
{maskSecret(selectedCipher.sshKey.decPrivateKey || '')}
</strong>
</div>
<div className="kv-actions" />
</div>
<div className="kv-row">
<span className="kv-label">{t('txt_public_key')}</span>
<div className="kv-main">
<strong className="value-ellipsis" title={selectedCipher.sshKey.decPublicKey || ''}>
{selectedCipher.sshKey.decPublicKey || ''}
</strong>
</div>
<div className="kv-actions" />
</div>
<div className="kv-row">
<span className="kv-label">{t('txt_fingerprint')}</span>
<div className="kv-main">
<strong className="value-ellipsis" title={selectedCipher.sshKey.decFingerprint || ''}>
{selectedCipher.sshKey.decFingerprint || ''}
</strong>
</div>
<div className="kv-actions" />
</div>
</div>
)}
@@ -1296,6 +1554,39 @@ function folderName(id: string | null | undefined): string {
</div>
)}
{selectedAttachments.some((attachment) => String(attachment?.id || '').trim()) && (
<div className="card">
<h4>{t('txt_attachments')}</h4>
<div className="attachment-list">
{selectedAttachments.map((attachment) => {
const attachmentId = String(attachment?.id || '').trim();
if (!attachmentId) return null;
const fileName = String(attachment.decFileName || attachment.fileName || attachmentId).trim() || attachmentId;
return (
<div key={`view-attachment-${attachmentId}`} className="attachment-row">
<div className="attachment-main">
<Paperclip size={14} />
<div className="attachment-text">
<strong className="value-ellipsis" title={fileName}>{fileName}</strong>
<span>{formatAttachmentSize(attachment)}</span>
</div>
</div>
<div className="kv-actions">
<button
type="button"
className="btn btn-secondary small"
onClick={() => void props.onDownloadAttachment(selectedCipher, attachmentId)}
>
<Download size={14} className="btn-icon" /> {t('txt_download')}
</button>
</div>
</div>
);
})}
</div>
</div>
)}
{(selectedCipher.creationDate || selectedCipher.revisionDate) && (
<div className="card">
<h4>{t('txt_item_history')}</h4>
@@ -1445,6 +1736,17 @@ function folderName(id: string | null | undefined): string {
</label>
</ConfirmDialog>
<ConfirmDialog
open={!!pendingDeleteFolder}
title={`${t('txt_delete')} ${t('txt_folder')}`}
message={`Delete folder "${pendingDeleteFolder?.decName || pendingDeleteFolder?.name || pendingDeleteFolder?.id || ''}"? Items inside will move to ${t('txt_no_folder')}.`}
confirmText={t('txt_delete')}
cancelText={t('txt_cancel')}
danger
onConfirm={() => void confirmDeleteFolder()}
onCancel={() => setPendingDeleteFolder(null)}
/>
<ConfirmDialog
open={repromptOpen}
title={t('txt_unlock_item')}
+344 -5
View File
@@ -80,6 +80,13 @@ export interface PreloginResult {
kdfIterations: number;
}
export interface PreloginKdfConfig {
kdfType: number;
kdfIterations: number;
kdfMemory: number | null;
kdfParallelism: number | null;
}
function randomHex(length: number): string {
const bytes = crypto.getRandomValues(new Uint8Array(Math.max(1, Math.ceil(length / 2))));
return Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('').slice(0, length);
@@ -130,6 +137,24 @@ export async function deriveLoginHash(email: string, password: string, fallbackI
return { hash: bytesToBase64(hash), masterKey, kdfIterations: iterations };
}
export async function getPreloginKdfConfig(email: string, fallbackIterations: number): Promise<PreloginKdfConfig> {
const normalized = String(email || '').trim().toLowerCase();
if (!normalized) throw new Error('Email is required');
const pre = await fetch('/identity/accounts/prelogin', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: normalized }),
});
if (!pre.ok) throw new Error('prelogin failed');
const data = (await parseJson<{ kdf?: number; kdfIterations?: number; kdfMemory?: number | null; kdfParallelism?: number | null }>(pre)) || {};
return {
kdfType: Number(data.kdf ?? 0) || 0,
kdfIterations: Number(data.kdfIterations || fallbackIterations),
kdfMemory: data.kdfMemory == null ? null : Number(data.kdfMemory),
kdfParallelism: data.kdfParallelism == null ? null : Number(data.kdfParallelism),
};
}
export async function loginWithPassword(
email: string,
passwordHash: string,
@@ -306,14 +331,54 @@ export async function getFolders(authedFetch: (input: string, init?: RequestInit
export async function createFolder(
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
session: SessionState,
name: string
): Promise<void> {
): Promise<{ id: string; name?: string | null }> {
if (!session.symEncKey || !session.symMacKey) throw new Error('Vault key unavailable');
const enc = base64ToBytes(session.symEncKey);
const mac = base64ToBytes(session.symMacKey);
const encryptedName = await encryptBw(new TextEncoder().encode(name), enc, mac);
const resp = await authedFetch('/api/folders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
body: JSON.stringify({ name: encryptedName }),
});
if (!resp.ok) throw new Error('Create folder failed');
const body = await parseJson<{ id?: string; name?: string | null }>(resp);
if (!body?.id) throw new Error('Create folder failed');
return { id: body.id, name: body.name ?? null };
}
export async function deleteFolder(
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
folderId: string
): Promise<void> {
const id = String(folderId || '').trim();
if (!id) throw new Error('Folder id is required');
const resp = await authedFetch(`/api/folders/${encodeURIComponent(id)}`, {
method: 'DELETE',
});
if (!resp.ok) throw new Error('Delete folder failed');
}
export async function updateFolder(
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
session: SessionState,
folderId: string,
name: string
): Promise<void> {
const id = String(folderId || '').trim();
if (!id) throw new Error('Folder id is required');
if (!session.symEncKey || !session.symMacKey) throw new Error('Vault key unavailable');
const enc = base64ToBytes(session.symEncKey);
const mac = base64ToBytes(session.symMacKey);
const encryptedName = await encryptBw(new TextEncoder().encode(name), enc, mac);
const resp = await authedFetch(`/api/folders/${encodeURIComponent(id)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: encryptedName }),
});
if (!resp.ok) throw new Error('Update folder failed');
}
export async function getCiphers(authedFetch: (input: string, init?: RequestInit) => Promise<Response>): Promise<Cipher[]> {
@@ -323,6 +388,221 @@ export async function getCiphers(authedFetch: (input: string, init?: RequestInit
return body?.data || [];
}
export interface CiphersImportPayload {
ciphers: Array<Record<string, unknown>>;
folders: Array<{ name: string }>;
folderRelationships: Array<{ key: number; value: number }>;
}
export interface ImportedCipherMapEntry {
index: number;
sourceId: string | null;
id: string;
}
export async function importCiphers(
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
payload: CiphersImportPayload,
options?: { returnCipherMap?: boolean }
): Promise<ImportedCipherMapEntry[] | null> {
const returnCipherMap = !!options?.returnCipherMap;
const url = returnCipherMap ? '/api/ciphers/import?returnCipherMap=1' : '/api/ciphers/import';
const resp = await authedFetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Import failed'));
if (!returnCipherMap) return null;
const body =
(await parseJson<{
cipherMap?: Array<{ index?: number; sourceId?: string | null; id?: string }>;
}>(resp)) || {};
if (!Array.isArray(body.cipherMap)) return [];
const out: ImportedCipherMapEntry[] = [];
for (const row of body.cipherMap) {
const index = Number(row?.index);
const id = String(row?.id || '').trim();
if (!Number.isFinite(index) || !id) continue;
const sourceRaw = String(row?.sourceId || '').trim();
out.push({
index,
id,
sourceId: sourceRaw || null,
});
}
return out;
}
export interface AttachmentDownloadInfo {
id: string;
url: string;
fileName: string | null;
key: string | null;
size: string | null;
sizeName: string | null;
}
export async function getAttachmentDownloadInfo(
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
cipherId: string,
attachmentId: string
): Promise<AttachmentDownloadInfo> {
const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(cipherId)}/attachment/${encodeURIComponent(attachmentId)}`);
if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Failed to load attachment'));
const body =
(await parseJson<{
id?: string;
url?: string;
fileName?: string | null;
key?: string | null;
size?: string | null;
sizeName?: string | null;
}>(resp)) || {};
const id = String(body.id || attachmentId || '').trim();
const url = String(body.url || '').trim();
if (!id || !url) throw new Error('Invalid attachment download response');
return {
id,
url,
fileName: body.fileName ?? null,
key: body.key ?? null,
size: body.size ?? null,
sizeName: body.sizeName ?? null,
};
}
function looksLikeCipherString(value: unknown): boolean {
return /^\d+\.[A-Za-z0-9+/=]+\|[A-Za-z0-9+/=]+(?:\|[A-Za-z0-9+/=]+)?$/.test(String(value || '').trim());
}
export async function uploadCipherAttachment(
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
session: SessionState,
cipherId: string,
file: File,
cipherForKey?: Cipher | null
): Promise<void> {
if (!session.symEncKey || !session.symMacKey) throw new Error('Vault key unavailable');
const id = String(cipherId || '').trim();
if (!id) throw new Error('Cipher id is required');
if (!file) throw new Error('File is required');
const userEnc = base64ToBytes(session.symEncKey);
const userMac = base64ToBytes(session.symMacKey);
const itemKeys = await getCipherKeys(cipherForKey || null, userEnc, userMac);
const encryptedFileName = await encryptTextValue(file.name, itemKeys.enc, itemKeys.mac);
if (!encryptedFileName) throw new Error('Invalid attachment name');
const attachmentRawKey = crypto.getRandomValues(new Uint8Array(64));
const attachmentWrappedKey = await encryptBw(attachmentRawKey, itemKeys.enc, itemKeys.mac);
const fileBytes = new Uint8Array(await file.arrayBuffer());
const encryptedBytes = await encryptBwFileData(fileBytes, attachmentRawKey.slice(0, 32), attachmentRawKey.slice(32, 64));
const metaResp = await authedFetch(`/api/ciphers/${encodeURIComponent(id)}/attachment/v2`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fileName: encryptedFileName,
key: attachmentWrappedKey,
fileSize: encryptedBytes.byteLength,
}),
});
if (!metaResp.ok) throw new Error(await parseErrorMessage(metaResp, 'Create attachment failed'));
const meta =
(await parseJson<{
attachmentId?: string;
url?: string;
}>(metaResp)) || {};
const attachmentId = String(meta.attachmentId || '').trim();
const uploadUrl = String(meta.url || '').trim();
if (!attachmentId || !uploadUrl) throw new Error('Create attachment failed');
const payload = new ArrayBuffer(encryptedBytes.byteLength);
new Uint8Array(payload).set(encryptedBytes);
const formData = new FormData();
formData.set('data', new Blob([payload], { type: 'application/octet-stream' }), encryptedFileName);
const uploadResp = await authedFetch(uploadUrl, {
method: 'POST',
body: formData,
});
if (!uploadResp.ok) {
try {
await authedFetch(`/api/ciphers/${encodeURIComponent(id)}/attachment/${encodeURIComponent(attachmentId)}`, { method: 'DELETE' });
} catch {
// ignore rollback failure
}
throw new Error(await parseErrorMessage(uploadResp, 'Upload attachment failed'));
}
}
export async function deleteCipherAttachment(
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
cipherId: string,
attachmentId: string
): Promise<void> {
const cid = String(cipherId || '').trim();
const aid = String(attachmentId || '').trim();
if (!cid || !aid) throw new Error('Attachment id is required');
const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(cid)}/attachment/${encodeURIComponent(aid)}`, {
method: 'DELETE',
});
if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Delete attachment failed'));
}
export async function downloadCipherAttachmentDecrypted(
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
session: SessionState,
cipher: Cipher,
attachmentId: string
): Promise<{ fileName: string; bytes: Uint8Array }> {
if (!session.symEncKey || !session.symMacKey) throw new Error('Vault key unavailable');
const cid = String(cipher?.id || '').trim();
const aid = String(attachmentId || '').trim();
if (!cid || !aid) throw new Error('Attachment id is required');
const info = await getAttachmentDownloadInfo(authedFetch, cid, aid);
const rawResp = await fetch(info.url, { cache: 'no-store' });
if (!rawResp.ok) throw new Error('Download attachment failed');
const encryptedBytes = new Uint8Array(await rawResp.arrayBuffer());
const userEnc = base64ToBytes(session.symEncKey);
const userMac = base64ToBytes(session.symMacKey);
const itemKeys = await getCipherKeys(cipher, userEnc, userMac);
let fileEnc = itemKeys.enc;
let fileMac = itemKeys.mac;
const keyCipher = String(info.key || '').trim();
if (keyCipher && looksLikeCipherString(keyCipher)) {
try {
const fileRawKey = await decryptBw(keyCipher, itemKeys.enc, itemKeys.mac);
if (fileRawKey.length >= 64) {
fileEnc = fileRawKey.slice(0, 32);
fileMac = fileRawKey.slice(32, 64);
}
} catch {
// fallback to item key
}
}
const plainBytes = await decryptBwFileData(encryptedBytes, fileEnc, fileMac);
const fileNameRaw = String(info.fileName || '').trim();
let fileName = fileNameRaw || `attachment-${aid}`;
if (fileNameRaw && looksLikeCipherString(fileNameRaw)) {
try {
fileName = (await decryptStr(fileNameRaw, itemKeys.enc, itemKeys.mac)) || fileName;
} catch {
// keep fallback name
}
}
return { fileName, bytes: plainBytes };
}
export async function getSends(authedFetch: (input: string, init?: RequestInit) => Promise<Response>): Promise<Send[]> {
const resp = await authedFetch('/api/sends');
if (!resp.ok) throw new Error('Failed to load sends');
@@ -571,6 +851,50 @@ async function encryptUris(uris: string[], enc: Uint8Array, mac: Uint8Array): Pr
return out;
}
function asFidoString(value: unknown, fallback = ''): string {
const normalized = String(value ?? '').trim();
return normalized || fallback;
}
function asNullableFidoString(value: unknown): string | null {
const normalized = String(value ?? '').trim();
return normalized || null;
}
function toIsoDateOrNow(value: unknown): string {
const raw = String(value ?? '').trim();
if (!raw) return new Date().toISOString();
const parsed = new Date(raw);
if (!Number.isFinite(parsed.getTime())) return new Date().toISOString();
return parsed.toISOString();
}
function normalizeFido2Credentials(
credentials: Array<Record<string, unknown>> | null | undefined
): Array<Record<string, unknown>> | null {
if (!Array.isArray(credentials) || credentials.length === 0) return null;
const out: Array<Record<string, unknown>> = [];
for (const credential of credentials) {
if (!credential || typeof credential !== 'object') continue;
out.push({
credentialId: asFidoString(credential.credentialId),
keyType: asFidoString(credential.keyType, 'public-key'),
keyAlgorithm: asFidoString(credential.keyAlgorithm, 'ECDSA'),
keyCurve: asFidoString(credential.keyCurve, 'P-256'),
keyValue: asFidoString(credential.keyValue),
rpId: asFidoString(credential.rpId),
rpName: asNullableFidoString(credential.rpName),
userHandle: asNullableFidoString(credential.userHandle),
userName: asNullableFidoString(credential.userName),
userDisplayName: asNullableFidoString(credential.userDisplayName),
counter: asFidoString(credential.counter, '0'),
discoverable: asFidoString(credential.discoverable, 'false'),
creationDate: toIsoDateOrNow(credential.creationDate),
});
}
return out.length ? out : null;
}
async function getCipherKeys(cipher: Cipher | null, userEnc: Uint8Array, userMac: Uint8Array): Promise<{ enc: Uint8Array; mac: Uint8Array; key: string | null }> {
if (cipher?.key) {
try {
@@ -587,7 +911,7 @@ export async function createCipher(
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
session: SessionState,
draft: VaultDraft
): Promise<void> {
): Promise<{ id: string }> {
if (!session.symEncKey || !session.symMacKey) throw new Error('Vault key unavailable');
const enc = base64ToBytes(session.symEncKey);
const mac = base64ToBytes(session.symMacKey);
@@ -613,6 +937,7 @@ export async function createCipher(
username: await encryptTextValue(draft.loginUsername, enc, mac),
password: await encryptTextValue(draft.loginPassword, enc, mac),
totp: await encryptTextValue(draft.loginTotp, enc, mac),
fido2Credentials: normalizeFido2Credentials(draft.loginFido2Credentials),
uris: await encryptUris(draft.loginUris || [], enc, mac),
};
} else if (type === 3) {
@@ -646,10 +971,13 @@ export async function createCipher(
country: await encryptTextValue(draft.identCountry, enc, mac),
};
} else if (type === 5) {
const encryptedFingerprint = await encryptTextValue(draft.sshFingerprint, enc, mac);
payload.sshKey = {
privateKey: await encryptTextValue(draft.sshPrivateKey, enc, mac),
publicKey: await encryptTextValue(draft.sshPublicKey, enc, mac),
fingerprint: await encryptTextValue(draft.sshFingerprint, enc, mac),
keyFingerprint: encryptedFingerprint,
// Keep legacy alias for backward compatibility with previously exported/edited items.
fingerprint: encryptedFingerprint,
};
} else if (type === 2) {
payload.secureNote = { type: 0 };
@@ -661,6 +989,9 @@ export async function createCipher(
body: JSON.stringify(payload),
});
if (!resp.ok) throw new Error('Create item failed');
const body = await parseJson<{ id?: string }>(resp);
if (!body?.id) throw new Error('Create item failed');
return { id: body.id };
}
export async function updateCipher(
@@ -693,10 +1024,15 @@ export async function updateCipher(
};
if (type === 1) {
const existingFido2 =
cipher.login && Array.isArray((cipher.login as any).fido2Credentials)
? (cipher.login as any).fido2Credentials
: null;
payload.login = {
username: await encryptTextValue(draft.loginUsername, keys.enc, keys.mac),
password: await encryptTextValue(draft.loginPassword, keys.enc, keys.mac),
totp: await encryptTextValue(draft.loginTotp, keys.enc, keys.mac),
fido2Credentials: normalizeFido2Credentials(existingFido2),
uris: await encryptUris(draft.loginUris || [], keys.enc, keys.mac),
};
} else if (type === 3) {
@@ -730,10 +1066,13 @@ export async function updateCipher(
country: await encryptTextValue(draft.identCountry, keys.enc, keys.mac),
};
} else if (type === 5) {
const encryptedFingerprint = await encryptTextValue(draft.sshFingerprint, keys.enc, keys.mac);
payload.sshKey = {
privateKey: await encryptTextValue(draft.sshPrivateKey, keys.enc, keys.mac),
publicKey: await encryptTextValue(draft.sshPublicKey, keys.enc, keys.mac),
fingerprint: await encryptTextValue(draft.sshFingerprint, keys.enc, keys.mac),
keyFingerprint: encryptedFingerprint,
// Keep legacy alias for backward compatibility with previously exported/edited items.
fingerprint: encryptedFingerprint,
};
} else if (type === 2) {
payload.secureNote = { type: 0 };
+711
View File
@@ -0,0 +1,711 @@
import { argon2idAsync } from '@noble/hashes/argon2.js';
import { strToU8, zipSync } from 'fflate';
import { Uint8ArrayReader, Uint8ArrayWriter, ZipReader, ZipWriter, configure as configureZipJs } from '@zip.js/zip.js';
import type { PreloginKdfConfig } from './api';
import { base64ToBytes, bytesToBase64, decryptBw, decryptStr, encryptBw, hkdfExpand, pbkdf2 } from './crypto';
import type { Cipher, Folder } from './types';
configureZipJs({ useWebWorkers: false });
export const EXPORT_FORMATS = [
{ id: 'bitwarden_json', label: 'Bitwarden (vault as json)' },
{ id: 'bitwarden_encrypted_json', label: 'Bitwarden (encrypted vault as json)' },
{ id: 'bitwarden_json_zip', label: 'Bitwarden (vault + attachments as zip)' },
{ id: 'bitwarden_encrypted_json_zip', label: 'Bitwarden (encrypted vault + attachments as zip)' },
{ id: 'nodewarden_json', label: 'NodeWarden (vault + attachments as json)' },
{ id: 'nodewarden_encrypted_json', label: 'NodeWarden (encrypted vault + attachments as json)' },
] as const;
export type ExportFormatId = (typeof EXPORT_FORMATS)[number]['id'];
export type EncryptedJsonMode = 'account' | 'password';
export interface ExportRequest {
format: ExportFormatId;
encryptedJsonMode?: EncryptedJsonMode;
filePassword?: string;
zipPassword?: string;
masterPassword?: string;
}
export interface ExportDownloadPayload {
fileName: string;
mimeType: string;
bytes: Uint8Array;
}
export interface ZipAttachmentEntry {
cipherId: string;
fileName: string;
bytes: Uint8Array;
}
export interface NodeWardenAttachmentRecord {
cipherId: string;
cipherIndex: number | null;
fileName: string;
data: string;
}
interface BuildPlainJsonArgs {
folders: Folder[];
ciphers: Cipher[];
userEncB64: string;
userMacB64: string;
}
interface BuildEncryptedJsonArgs {
folders: Folder[];
ciphers: Cipher[];
userEncB64: string;
userMacB64: string;
}
interface PasswordProtectedArgs {
plaintextJson: string;
password: string;
kdf: PreloginKdfConfig;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object';
}
function isCipherString(value: string): boolean {
return /^\d+\.[A-Za-z0-9+/=]+\|[A-Za-z0-9+/=]+(?:\|[A-Za-z0-9+/=]+)?$/.test(String(value || '').trim());
}
function normalizeString(value: unknown): string | null {
if (value === null || value === undefined) return null;
return String(value);
}
function normalizeNumber(value: unknown, fallback = 0): number {
const n = Number(value);
if (!Number.isFinite(n)) return fallback;
return n;
}
function cloneValue<T>(value: T): T {
if (value === null || value === undefined) return value;
if (typeof structuredClone === 'function') {
try {
return structuredClone(value);
} catch {
// ignore and fallback
}
}
try {
return JSON.parse(JSON.stringify(value)) as T;
} catch {
return value;
}
}
function randomGuid(): string {
if (typeof crypto.randomUUID === 'function') return crypto.randomUUID();
const bytes = crypto.getRandomValues(new Uint8Array(16));
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = Array.from(bytes)
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
function toAesBuffer(bytes: Uint8Array): ArrayBuffer {
return new Uint8Array(bytes).buffer;
}
async function getCipherKeyParts(cipher: Cipher, userEnc: Uint8Array, userMac: Uint8Array): Promise<{ enc: Uint8Array; mac: Uint8Array }> {
if (cipher.key && typeof cipher.key === 'string') {
try {
const raw = await decryptBw(cipher.key, userEnc, userMac);
if (raw.length >= 64) {
return { enc: raw.slice(0, 32), mac: raw.slice(32, 64) };
}
} catch {
// Fallback to user key.
}
}
return { enc: userEnc, mac: userMac };
}
async function decryptMaybe(value: unknown, enc: Uint8Array, mac: Uint8Array): Promise<string | null> {
if (value === null || value === undefined) return null;
if (typeof value !== 'string') return String(value);
const raw = value;
if (!raw) return '';
if (!isCipherString(raw)) return raw;
try {
return await decryptStr(raw, enc, mac);
} catch {
return raw;
}
}
async function deepDecryptUnknown(value: unknown, enc: Uint8Array, mac: Uint8Array): Promise<unknown> {
if (value === null || value === undefined) return value;
if (typeof value === 'string') return decryptMaybe(value, enc, mac);
if (Array.isArray(value)) {
return Promise.all(value.map((item) => deepDecryptUnknown(item, enc, mac)));
}
if (isRecord(value)) {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value)) {
out[k] = await deepDecryptUnknown(v, enc, mac);
}
return out;
}
return value;
}
function mapCipherCommonMetadata(cipher: Cipher): Record<string, unknown> {
const out: Record<string, unknown> = {
id: cipher.id,
type: normalizeNumber(cipher.type, 1),
reprompt: normalizeNumber(cipher.reprompt, 0),
favorite: !!cipher.favorite,
folderId: normalizeString(cipher.folderId),
creationDate: normalizeString(cipher.creationDate),
revisionDate: normalizeString(cipher.revisionDate),
collectionIds: null,
};
if ((out.creationDate as string | null) === null) delete out.creationDate;
if ((out.revisionDate as string | null) === null) delete out.revisionDate;
if ((out.folderId as string | null) === null) delete out.folderId;
return out;
}
function mapCipherEncrypted(cipher: Cipher): Record<string, unknown> {
const out = mapCipherCommonMetadata(cipher);
out.name = cipher.name ?? null;
out.notes = cipher.notes ?? null;
out.key = cipher.key ?? null;
out.fields = Array.isArray(cipher.fields)
? cipher.fields.map((field) => ({
name: field?.name ?? null,
value: field?.value ?? null,
type: normalizeNumber(field?.type, 0),
linkedId: field?.linkedId ?? null,
}))
: [];
const login = cipher.login;
out.login = login
? {
username: login.username ?? null,
password: login.password ?? null,
totp: login.totp ?? null,
uris: Array.isArray(login.uris)
? login.uris.map((uri) => ({
uri: uri?.uri ?? null,
match: (uri as { match?: unknown })?.match ?? null,
}))
: [],
fido2Credentials: Array.isArray(login.fido2Credentials) ? cloneValue(login.fido2Credentials) : [],
}
: null;
out.card = cipher.card
? {
cardholderName: cipher.card.cardholderName ?? null,
brand: cipher.card.brand ?? null,
number: cipher.card.number ?? null,
expMonth: cipher.card.expMonth ?? null,
expYear: cipher.card.expYear ?? null,
code: cipher.card.code ?? null,
}
: null;
out.identity = cipher.identity
? {
title: cipher.identity.title ?? null,
firstName: cipher.identity.firstName ?? null,
middleName: cipher.identity.middleName ?? null,
lastName: cipher.identity.lastName ?? null,
username: cipher.identity.username ?? null,
company: cipher.identity.company ?? null,
ssn: cipher.identity.ssn ?? null,
passportNumber: cipher.identity.passportNumber ?? null,
licenseNumber: cipher.identity.licenseNumber ?? null,
email: cipher.identity.email ?? null,
phone: cipher.identity.phone ?? null,
address1: cipher.identity.address1 ?? null,
address2: cipher.identity.address2 ?? null,
address3: cipher.identity.address3 ?? null,
city: cipher.identity.city ?? null,
state: cipher.identity.state ?? null,
postalCode: cipher.identity.postalCode ?? null,
country: cipher.identity.country ?? null,
}
: null;
out.secureNote = cipher.secureNote
? {
type: normalizeNumber((cipher.secureNote as { type?: unknown }).type, 0),
}
: null;
out.passwordHistory = Array.isArray(cipher.passwordHistory)
? cipher.passwordHistory.map((entry) => ({
password: (entry as { password?: unknown }).password ?? null,
lastUsedDate: (entry as { lastUsedDate?: unknown }).lastUsedDate ?? null,
}))
: [];
out.sshKey = cipher.sshKey
? {
privateKey: cipher.sshKey.privateKey ?? null,
publicKey: cipher.sshKey.publicKey ?? null,
keyFingerprint: cipher.sshKey.keyFingerprint ?? cipher.sshKey.fingerprint ?? null,
// Keep legacy alias for compatibility with older importers.
fingerprint: cipher.sshKey.keyFingerprint ?? cipher.sshKey.fingerprint ?? null,
}
: null;
return out;
}
async function mapCipherPlain(cipher: Cipher, userEnc: Uint8Array, userMac: Uint8Array): Promise<Record<string, unknown>> {
const keyParts = await getCipherKeyParts(cipher, userEnc, userMac);
const out = mapCipherCommonMetadata(cipher);
out.name = await decryptMaybe(cipher.name ?? null, keyParts.enc, keyParts.mac);
out.notes = await decryptMaybe(cipher.notes ?? null, keyParts.enc, keyParts.mac);
out.fields = Array.isArray(cipher.fields)
? await Promise.all(
cipher.fields.map(async (field) => ({
name: await decryptMaybe(field?.name ?? null, keyParts.enc, keyParts.mac),
value: await decryptMaybe(field?.value ?? null, keyParts.enc, keyParts.mac),
type: normalizeNumber(field?.type, 0),
linkedId: field?.linkedId ?? null,
}))
)
: [];
if (cipher.login) {
out.login = {
username: await decryptMaybe(cipher.login.username ?? null, keyParts.enc, keyParts.mac),
password: await decryptMaybe(cipher.login.password ?? null, keyParts.enc, keyParts.mac),
totp: await decryptMaybe(cipher.login.totp ?? null, keyParts.enc, keyParts.mac),
uris: Array.isArray(cipher.login.uris)
? await Promise.all(
cipher.login.uris.map(async (uri) => ({
uri: await decryptMaybe(uri?.uri ?? null, keyParts.enc, keyParts.mac),
match: (uri as { match?: unknown })?.match ?? null,
}))
)
: [],
fido2Credentials: Array.isArray(cipher.login.fido2Credentials)
? await Promise.all(cipher.login.fido2Credentials.map((credential) => deepDecryptUnknown(credential, keyParts.enc, keyParts.mac)))
: [],
};
} else {
out.login = null;
}
out.card = cipher.card ? await deepDecryptUnknown(cipher.card, keyParts.enc, keyParts.mac) : null;
out.identity = cipher.identity ? await deepDecryptUnknown(cipher.identity, keyParts.enc, keyParts.mac) : null;
if (cipher.sshKey) {
const fingerprint = await decryptMaybe(
cipher.sshKey.keyFingerprint ?? cipher.sshKey.fingerprint ?? null,
keyParts.enc,
keyParts.mac
);
out.sshKey = {
privateKey: await decryptMaybe(cipher.sshKey.privateKey ?? null, keyParts.enc, keyParts.mac),
publicKey: await decryptMaybe(cipher.sshKey.publicKey ?? null, keyParts.enc, keyParts.mac),
keyFingerprint: fingerprint,
// Keep legacy alias for compatibility with older importers.
fingerprint,
};
} else {
out.sshKey = null;
}
out.secureNote = cipher.secureNote
? {
type: normalizeNumber((cipher.secureNote as { type?: unknown }).type, 0),
}
: null;
out.passwordHistory = Array.isArray(cipher.passwordHistory)
? await Promise.all(
cipher.passwordHistory.map(async (entry) => ({
password: await decryptMaybe((entry as { password?: unknown }).password ?? null, keyParts.enc, keyParts.mac),
lastUsedDate: normalizeString((entry as { lastUsedDate?: unknown }).lastUsedDate),
}))
)
: [];
return out;
}
async function decryptFolderName(folder: Folder, userEnc: Uint8Array, userMac: Uint8Array): Promise<string> {
const value = await decryptMaybe(folder.name ?? '', userEnc, userMac);
return value || '';
}
function trimNullKeys(value: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value)) {
if (v !== undefined) out[k] = v;
}
return out;
}
function filterExportableCiphers(ciphers: Cipher[]): Cipher[] {
return ciphers.filter((cipher) => !cipher.deletedDate && !(cipher as { organizationId?: unknown }).organizationId);
}
export async function buildPlainBitwardenJsonDocument(args: BuildPlainJsonArgs): Promise<Record<string, unknown>> {
const userEnc = base64ToBytes(args.userEncB64);
const userMac = base64ToBytes(args.userMacB64);
const folders = await Promise.all(
args.folders.map(async (folder) => ({
id: folder.id,
name: await decryptFolderName(folder, userEnc, userMac),
}))
);
const items = await Promise.all(filterExportableCiphers(args.ciphers).map((cipher) => mapCipherPlain(cipher, userEnc, userMac)));
return {
encrypted: false,
folders,
items: items.map((item) => trimNullKeys(item)),
};
}
export async function buildPlainBitwardenJsonString(args: BuildPlainJsonArgs): Promise<string> {
const doc = await buildPlainBitwardenJsonDocument(args);
return JSON.stringify(doc, null, 2);
}
export async function buildBitwardenCsvString(args: BuildPlainJsonArgs): Promise<string> {
const doc = await buildPlainBitwardenJsonDocument(args);
const folders = Array.isArray(doc.folders) ? (doc.folders as Array<Record<string, unknown>>) : [];
const items = Array.isArray(doc.items) ? (doc.items as Array<Record<string, unknown>>) : [];
const folderNameById = new Map<string, string>();
for (const folder of folders) {
const id = normalizeString(folder.id);
if (!id) continue;
folderNameById.set(id, normalizeString(folder.name) || '');
}
const header = [
'folder',
'favorite',
'type',
'name',
'notes',
'fields',
'reprompt',
'archivedDate',
'login_uri',
'login_username',
'login_password',
'login_totp',
];
const rows: string[][] = [header];
for (const item of items) {
const type = normalizeNumber(item.type, 1);
if (type !== 1 && type !== 2) continue;
const folderId = normalizeString(item.folderId);
const folderName = folderId ? folderNameById.get(folderId) || '' : '';
const fields = Array.isArray(item.fields)
? (item.fields as Array<Record<string, unknown>>)
.map((field) => {
const name = normalizeString(field.name) || '';
const value = normalizeString(field.value) || '';
if (!name && !value) return '';
return `${name}: ${value}`;
})
.filter((line) => !!line)
.join('\n')
: '';
const login = isRecord(item.login) ? (item.login as Record<string, unknown>) : null;
const loginUris = login && Array.isArray(login.uris)
? (login.uris as Array<Record<string, unknown>>)
.map((uri) => normalizeString(uri.uri) || '')
.filter((uri) => !!uri)
.join(',')
: '';
rows.push([
folderName,
item.favorite ? '1' : '',
type === 1 ? 'login' : 'note',
normalizeString(item.name) || '',
normalizeString(item.notes) || '',
fields,
String(normalizeNumber(item.reprompt, 0)),
normalizeString(item.archivedDate) || '',
loginUris,
normalizeString(login?.username) || '',
normalizeString(login?.password) || '',
normalizeString(login?.totp) || '',
]);
}
const escapeCsv = (value: string): string => {
if (/[",\n\r]/.test(value)) {
return `"${value.replace(/"/g, '""')}"`;
}
return value;
};
return rows.map((row) => row.map((cell) => escapeCsv(String(cell || ''))).join(',')).join('\n');
}
export async function buildAccountEncryptedBitwardenJsonString(args: BuildEncryptedJsonArgs): Promise<string> {
const userEnc = base64ToBytes(args.userEncB64);
const userMac = base64ToBytes(args.userMacB64);
const validation = await encryptBw(new TextEncoder().encode(randomGuid()), userEnc, userMac);
const folders = args.folders.map((folder) => ({
id: folder.id,
name: folder.name,
}));
const items = filterExportableCiphers(args.ciphers).map((cipher) => mapCipherEncrypted(cipher));
const doc = {
encrypted: true,
encKeyValidation_DO_NOT_EDIT: validation,
folders,
items,
};
return JSON.stringify(doc, null, 2);
}
async function derivePasswordProtectedKey(kdf: PreloginKdfConfig, password: string, saltB64: string): Promise<{ enc: Uint8Array; mac: Uint8Array }> {
const iterations = Math.max(1, normalizeNumber(kdf.kdfIterations, 600000));
const kdfType = normalizeNumber(kdf.kdfType, 0);
const saltTextBytes = new TextEncoder().encode(saltB64);
let keyMaterial: Uint8Array;
if (kdfType === 1) {
const memoryMiB = Math.max(16, normalizeNumber(kdf.kdfMemory, 64));
const parallelism = Math.max(1, normalizeNumber(kdf.kdfParallelism, 4));
const memoryKiB = Math.floor(memoryMiB * 1024);
const maxmem = memoryKiB * 1024 + 1024 * 1024;
keyMaterial = await argon2idAsync(new TextEncoder().encode(password), saltTextBytes, {
t: Math.floor(iterations),
m: memoryKiB,
p: Math.floor(parallelism),
dkLen: 32,
maxmem,
asyncTick: 10,
});
} else {
keyMaterial = await pbkdf2(password, saltTextBytes, iterations, 32);
}
const enc = await hkdfExpand(keyMaterial, 'enc', 32);
const mac = await hkdfExpand(keyMaterial, 'mac', 32);
return { enc, mac };
}
export async function buildPasswordProtectedBitwardenJsonString(args: PasswordProtectedArgs): Promise<string> {
const password = String(args.password || '').trim();
if (!password) throw new Error('File password is required');
const salt = crypto.getRandomValues(new Uint8Array(16));
const saltB64 = bytesToBase64(salt);
const key = await derivePasswordProtectedKey(args.kdf, password, saltB64);
const validation = await encryptBw(new TextEncoder().encode(randomGuid()), key.enc, key.mac);
const data = await encryptBw(new TextEncoder().encode(args.plaintextJson), key.enc, key.mac);
const kdfType = normalizeNumber(args.kdf.kdfType, 0);
const out: Record<string, unknown> = {
encrypted: true,
passwordProtected: true,
salt: saltB64,
kdfType,
kdfIterations: Math.max(1, normalizeNumber(args.kdf.kdfIterations, 600000)),
encKeyValidation_DO_NOT_EDIT: validation,
data,
};
if (kdfType === 1) {
out.kdfMemory = Math.max(16, normalizeNumber(args.kdf.kdfMemory, 64));
out.kdfParallelism = Math.max(1, normalizeNumber(args.kdf.kdfParallelism, 4));
}
return JSON.stringify(out, null, 2);
}
function sanitizeFileName(name: string): string {
const normalized = String(name || '').trim().replace(/[\\/]/g, '_').replace(/[\x00-\x1F\x7F]/g, '');
if (!normalized) return 'attachment.bin';
if (normalized.length > 240) {
const dot = normalized.lastIndexOf('.');
if (dot > 0 && dot > normalized.length - 16) {
const ext = normalized.slice(dot);
return `${normalized.slice(0, 240 - ext.length)}${ext}`;
}
return normalized.slice(0, 240);
}
return normalized;
}
function uniqueAttachmentFileName(cipherId: string, originalName: string, used: Set<string>): string {
const safe = sanitizeFileName(originalName);
const keyBase = `${cipherId}/${safe}`;
if (!used.has(keyBase)) {
used.add(keyBase);
return safe;
}
const dot = safe.lastIndexOf('.');
const base = dot > 0 ? safe.slice(0, dot) : safe;
const ext = dot > 0 ? safe.slice(dot) : '';
let idx = 1;
while (idx < 10000) {
const candidate = `${base} (${idx})${ext}`;
const key = `${cipherId}/${candidate}`;
if (!used.has(key)) {
used.add(key);
return candidate;
}
idx += 1;
}
return `${base}-${Date.now()}${ext}`;
}
export function buildBitwardenZipBytes(dataJson: string, attachments: ZipAttachmentEntry[]): Uint8Array {
const files: Record<string, Uint8Array> = {
'data.json': strToU8(dataJson),
};
const used = new Set<string>();
for (const attachment of attachments) {
const cipherId = String(attachment.cipherId || '').trim();
if (!cipherId) continue;
const fileName = uniqueAttachmentFileName(cipherId, attachment.fileName || 'attachment.bin', used);
files[`attachments/${cipherId}/${fileName}`] = attachment.bytes;
}
return zipSync(files, { level: 6 });
}
export async function encryptZipBytesWithPassword(
zipBytes: Uint8Array,
passwordRaw: string
): Promise<{ bytes: Uint8Array; encrypted: boolean }> {
const password = String(passwordRaw || '').trim();
if (!password) return { bytes: zipBytes, encrypted: false };
const zipReader = new ZipReader(new Uint8ArrayReader(zipBytes), { useWebWorkers: false });
const zipWriter = new ZipWriter(new Uint8ArrayWriter(), { useWebWorkers: false });
try {
const entries = await zipReader.getEntries();
for (const entry of entries) {
const filename = String(entry.filename || '').trim();
if (!filename) continue;
if (entry.directory) {
await zipWriter.add(filename, undefined, {
directory: true,
password,
encryptionStrength: 3,
});
continue;
}
const data = await entry.getData(new Uint8ArrayWriter());
await zipWriter.add(filename, new Uint8ArrayReader(data), {
password,
encryptionStrength: 3,
level: 6,
});
}
return {
bytes: await zipWriter.close(),
encrypted: true,
};
} finally {
await zipReader.close();
}
}
function nowStamp(now = new Date()): string {
const y = now.getFullYear();
const m = String(now.getMonth() + 1).padStart(2, '0');
const d = String(now.getDate()).padStart(2, '0');
const hh = String(now.getHours()).padStart(2, '0');
const mm = String(now.getMinutes()).padStart(2, '0');
const ss = String(now.getSeconds()).padStart(2, '0');
return `${y}${m}${d}_${hh}${mm}${ss}`;
}
export function buildExportFileName(format: ExportFormatId, zipEncrypted = false): string {
const stamp = nowStamp();
if (
format === 'bitwarden_json' ||
format === 'bitwarden_encrypted_json' ||
format === 'nodewarden_json' ||
format === 'nodewarden_encrypted_json'
) {
if (format.startsWith('nodewarden_')) return `nodewarden_export_${stamp}.json`;
return `bitwarden_export_${stamp}.json`;
}
if (format === 'bitwarden_json_zip' || format === 'bitwarden_encrypted_json_zip') {
if (zipEncrypted) return `bitwarden_export_${stamp}.zip`;
return `bitwarden_export_${stamp}.zip`;
}
return `bitwarden_export_${stamp}.bin`;
}
export function buildNodeWardenAttachmentRecords(
attachments: ZipAttachmentEntry[],
cipherIndexById?: Map<string, number>
): NodeWardenAttachmentRecord[] {
const out: NodeWardenAttachmentRecord[] = [];
for (const attachment of attachments) {
const cipherId = String(attachment.cipherId || '').trim();
if (!cipherId) continue;
const fileName = sanitizeFileName(String(attachment.fileName || '').trim() || 'attachment.bin');
out.push({
cipherId,
cipherIndex: cipherIndexById?.get(cipherId) ?? null,
fileName,
data: bytesToBase64(attachment.bytes),
});
}
return out;
}
export function buildNodeWardenPlainJsonDocument(
bitwardenJsonDoc: Record<string, unknown>,
attachments: NodeWardenAttachmentRecord[]
): Record<string, unknown> {
return {
...bitwardenJsonDoc,
nodewardenFormat: 'nodewarden_json',
nodewardenVersion: 1,
nodewardenAttachments: attachments,
};
}
export async function attachNodeWardenEncryptedAttachmentPayload(
encryptedBitwardenJson: string,
attachments: NodeWardenAttachmentRecord[],
userEncB64: string,
userMacB64: string
): Promise<string> {
const parsed = JSON.parse(encryptedBitwardenJson) as Record<string, unknown>;
const userEnc = base64ToBytes(userEncB64);
const userMac = base64ToBytes(userMacB64);
const payload = JSON.stringify({
nodewardenFormat: 'nodewarden_json',
nodewardenVersion: 1,
nodewardenAttachments: attachments,
});
parsed.nodewardenFormat = 'nodewarden_json';
parsed.nodewardenVersion = 1;
parsed.nodewardenAttachmentsEnc = await encryptBw(new TextEncoder().encode(payload), userEnc, userMac);
return JSON.stringify(parsed, null, 2);
}
+107 -1
View File
@@ -326,6 +326,12 @@ const messages: Record<Locale, Record<string, string>> = {
txt_totp_is_enabled_for_this_account: "TOTP is enabled for this account.",
txt_totp_secret: "TOTP Secret",
txt_totp_verify_failed: "TOTP verify failed",
txt_passkey: "Passkey",
txt_passkey_created_at_value: "Created at {value}",
txt_attachments: "Attachments",
txt_upload_attachments: "Upload attachments",
txt_new_attachments: "New attachments",
txt_marked_for_removal_count: "{count} attachment(s) will be removed on save",
txt_trash: "Trash",
txt_trust_this_device_for_30_days: "Trust this device for 30 days",
txt_trusted_until: "Trusted Until",
@@ -727,7 +733,107 @@ const zhCNOverrides: Record<string, string> = {
txt_copied: '已复制',
};
zhCNOverrides.txt_lock = '\u9501\u5b9a';
zhCNOverrides.txt_lock = '锁定';
zhCNOverrides.txt_passkey = 'Passkey';
zhCNOverrides.txt_passkey_created_at_value = '创建于 {value}';
zhCNOverrides.txt_attachments = '附件';
zhCNOverrides.txt_upload_attachments = '上传附件';
zhCNOverrides.txt_new_attachments = '待上传附件';
zhCNOverrides.txt_marked_for_removal_count = '保存后将删除 {count} 个附件';
messages.en.txt_import = 'Import';
messages.en.txt_export = 'Export';
messages.en.txt_format = 'Format';
messages.en.txt_source_file = 'Source file';
messages.en.txt_folder_handling = 'Folder handling';
messages.en.txt_import_folder_mode_original = 'Original path from import file';
messages.en.txt_import_folder_mode_none = 'No folder';
messages.en.txt_import_folder_mode_target = 'One selected folder';
messages.en.txt_target_folder = 'Target folder';
messages.en.txt_select_folder_placeholder = '-- Select folder --';
messages.en.txt_import_vault_data_hint = 'Import vault data into your current account.';
messages.en.txt_export_vault_data_hint = 'Export vault data from your current account.';
messages.en.txt_import_export_title = 'Import & Export';
messages.en.txt_import_export_feature_intro = 'Provides standardized vault migration across clients, including attachment-aware and encrypted workflows.';
messages.en.txt_import_export_feature_bw_zip_title = 'Bitwarden vault + attachments ZIP';
messages.en.txt_import_export_feature_bw_zip_desc = 'Supports both import and export for Bitwarden ZIP archives containing vault data and attachments.';
messages.en.txt_import_export_feature_nodewarden_json_title = 'NodeWarden vault + attachments JSON';
messages.en.txt_import_export_feature_nodewarden_json_desc = 'Supports NodeWarden JSON import/export with vault and attachment payloads in a single document. Exported vault data remains importable by Bitwarden clients.';
messages.en.txt_import_export_feature_compat_title = 'Cross-client compatibility';
messages.en.txt_import_export_feature_compat_desc = 'Supports Bitwarden JSON/CSV and mainstream migration formats with consistent field normalization and import mapping.';
messages.en.txt_encrypted_mode = 'Encrypted mode';
messages.en.txt_account_verification = 'Account verification';
messages.en.txt_password_verification = 'Password verification';
messages.en.txt_file_password = 'File password';
messages.en.txt_zip_password_optional = 'ZIP password (optional)';
messages.en.txt_zip_password = 'ZIP password';
messages.en.txt_close = 'Close';
messages.en.txt_total = 'Total';
messages.en.txt_import_success = 'Import successful';
messages.en.txt_import_success_number_of_items = 'Imported {count} item(s) in total.';
messages.en.txt_import_file_password_required = 'Please enter file password.';
messages.en.txt_import_invalid_zip_password = 'Invalid ZIP password.';
messages.en.txt_export_completed = 'Export completed';
messages.en.txt_export_failed = 'Export failed';
messages.en.txt_import_invalid_password_protected_file = 'Invalid password-protected export file.';
messages.en.txt_import_decrypt_failed = 'Failed to decrypt import file.';
messages.en.txt_import_empty_zip_archive = 'Empty zip archive.';
messages.en.txt_import_no_json_found_in_zip = 'No importable JSON data found in zip archive.';
messages.en.txt_import_data_json_not_found = 'data.json not found in zip archive.';
messages.en.txt_import_zip_password_required = 'ZIP password is required.';
messages.en.txt_import_invalid_json_file = 'Invalid JSON file';
messages.en.txt_import_failed = 'Import failed';
messages.en.txt_import_encrypted_file_title = 'Import encrypted file';
messages.en.txt_import_encrypted_file_message = 'This Bitwarden export is password-protected. Enter the export file password to continue.';
messages.en.txt_import_encrypted_zip_title = 'Import encrypted ZIP';
messages.en.txt_import_encrypted_zip_message = 'This ZIP archive is password-protected. Enter the ZIP password to continue.';
zhCNOverrides.txt_import = '导入';
zhCNOverrides.txt_export = '导出';
zhCNOverrides.txt_format = '格式';
zhCNOverrides.txt_source_file = '源文件';
zhCNOverrides.txt_folder_handling = '文件夹处理';
zhCNOverrides.txt_import_folder_mode_original = '保留导入文件中的原始路径';
zhCNOverrides.txt_import_folder_mode_none = '不使用文件夹';
zhCNOverrides.txt_import_folder_mode_target = '导入到指定文件夹';
zhCNOverrides.txt_target_folder = '目标文件夹';
zhCNOverrides.txt_select_folder_placeholder = '-- 选择文件夹 --';
zhCNOverrides.txt_import_vault_data_hint = '将数据导入到当前账号。';
zhCNOverrides.txt_export_vault_data_hint = '从当前账号导出数据。';
zhCNOverrides.txt_encrypted_mode = '加密方式';
zhCNOverrides.txt_account_verification = '账号验证';
zhCNOverrides.txt_password_verification = '密码验证';
zhCNOverrides.txt_file_password = '文件密码';
zhCNOverrides.txt_zip_password_optional = 'ZIP 密码(可选)';
zhCNOverrides.txt_zip_password = 'ZIP 密码';
zhCNOverrides.txt_close = '关闭';
zhCNOverrides.txt_total = '总计';
zhCNOverrides.txt_import_success = '数据导入成功';
zhCNOverrides.txt_import_success_number_of_items = '一共导入了 {count} 个项目。';
zhCNOverrides.txt_import_file_password_required = '请输入文件密码。';
zhCNOverrides.txt_import_invalid_zip_password = 'ZIP 密码错误。';
zhCNOverrides.txt_export_completed = '导出完成';
zhCNOverrides.txt_export_failed = '导出失败';
zhCNOverrides.txt_import_invalid_password_protected_file = '密码保护导出文件格式无效。';
zhCNOverrides.txt_import_decrypt_failed = '导入文件解密失败。';
zhCNOverrides.txt_import_empty_zip_archive = 'ZIP 压缩包为空。';
zhCNOverrides.txt_import_no_json_found_in_zip = 'ZIP 内未找到可导入的 JSON 数据。';
zhCNOverrides.txt_import_data_json_not_found = 'ZIP 内未找到 data.json。';
zhCNOverrides.txt_import_zip_password_required = '该 ZIP 需要密码。';
zhCNOverrides.txt_import_invalid_json_file = 'JSON 文件无效';
zhCNOverrides.txt_import_failed = '导入失败';
zhCNOverrides.txt_import_encrypted_file_title = '导入加密文件';
zhCNOverrides.txt_import_encrypted_file_message = '该 Bitwarden 导出文件已加密,请输入文件密码继续。';
zhCNOverrides.txt_import_encrypted_zip_title = '导入加密 ZIP';
zhCNOverrides.txt_import_encrypted_zip_message = '该 ZIP 压缩包已加密,请输入 ZIP 密码继续。';
zhCNOverrides.txt_import_export_title = '导入导出';
zhCNOverrides.txt_import_export_feature_intro = '提供标准化的数据迁移能力,覆盖附件与加密场景。';
zhCNOverrides.txt_import_export_feature_bw_zip_title = 'Bitwarden 密码库 + 附件 ZIP';
zhCNOverrides.txt_import_export_feature_bw_zip_desc = '支持导入与导出包含密码库和附件的 Bitwarden ZIP 压缩包。';
zhCNOverrides.txt_import_export_feature_nodewarden_json_title = 'NodeWarden 密码库 + 附件 JSON';
zhCNOverrides.txt_import_export_feature_nodewarden_json_desc = '支持 NodeWarden JSON 导入导出,单文件包含密码库与附件;导出的密码库数据可被 Bitwarden 客户端导入。';
zhCNOverrides.txt_import_export_feature_compat_title = '跨客户端兼容';
zhCNOverrides.txt_import_export_feature_compat_desc = '支持 Bitwarden JSON/CSV 与主流迁移格式,统一字段映射与导入行为。';
messages['zh-CN'] = { ...messages.en, ...zhCNOverrides };
File diff suppressed because it is too large Load Diff
+23
View File
@@ -28,11 +28,28 @@ export interface CipherLoginUri {
decUri?: string;
}
export interface CipherAttachment {
id?: string;
url?: string | null;
fileName?: string | null;
decFileName?: string;
key?: string | null;
size?: string | number | null;
sizeName?: string | null;
object?: string;
}
export interface CipherLoginPasskey {
creationDate?: string | null;
[key: string]: unknown;
}
export interface CipherLogin {
username?: string | null;
password?: string | null;
totp?: string | null;
uris?: CipherLoginUri[] | null;
fido2Credentials?: CipherLoginPasskey[] | null;
decUsername?: string;
decPassword?: string;
decTotp?: string;
@@ -95,6 +112,7 @@ export interface CipherIdentity {
export interface CipherSshKey {
privateKey?: string | null;
publicKey?: string | null;
keyFingerprint?: string | null;
fingerprint?: string | null;
decPrivateKey?: string;
decPublicKey?: string;
@@ -105,6 +123,7 @@ export interface CipherField {
type?: number | string | null;
name?: string | null;
value?: string | null;
linkedId?: number | null;
decName?: string;
decValue?: string;
}
@@ -121,10 +140,13 @@ export interface Cipher {
creationDate?: string;
revisionDate?: string;
deletedDate?: string | null;
attachments?: CipherAttachment[] | null;
login?: CipherLogin | null;
card?: CipherCard | null;
identity?: CipherIdentity | null;
sshKey?: CipherSshKey | null;
secureNote?: { type?: number | null } | null;
passwordHistory?: Array<{ password?: string | null; lastUsedDate?: string | null }> | null;
fields?: CipherField[] | null;
decName?: string;
decNotes?: string;
@@ -196,6 +218,7 @@ export interface VaultDraft {
loginPassword: string;
loginTotp: string;
loginUris: string[];
loginFido2Credentials: Array<Record<string, unknown>>;
cardholderName: string;
cardNumber: string;
cardBrand: string;
+235
View File
@@ -321,6 +321,7 @@ input[type='file'].input::file-selector-button:hover {
width: 100%;
height: 50px;
font-size: 22px;
margin: 10px 0;
}
.btn.small {
@@ -622,6 +623,36 @@ input[type='file'].input::file-selector-button:hover {
text-overflow: ellipsis;
}
.folder-row {
display: flex;
align-items: center;
gap: 6px;
}
.folder-row .tree-btn {
margin-bottom: 0;
}
.folder-delete-btn {
border: none;
background: transparent;
color: #64748b;
width: 24px;
height: 24px;
padding: 0;
cursor: pointer;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 6px;
}
.folder-delete-btn:hover {
color: #b91c1c;
background: #fee2e2;
}
.list-col {
display: flex;
flex-direction: column;
@@ -900,6 +931,74 @@ input[type='file'].input::file-selector-button:hover {
flex-shrink: 0;
}
.attachment-list {
display: grid;
gap: 0;
}
.attachment-head {
margin-bottom: 8px;
}
.attachment-head h4 {
margin-bottom: 0;
}
.attachment-add-btn {
min-width: 32px;
padding: 0 8px;
}
.attachment-file-input {
display: none;
}
.attachment-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
border-bottom: 1px solid #ecf0f5;
padding: 10px 0;
}
.attachment-row:last-child {
border-bottom: none;
}
.attachment-main {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.attachment-text {
min-width: 0;
display: grid;
gap: 2px;
}
.attachment-text span {
color: #64748b;
font-size: 12px;
}
.attachment-row.is-removed {
opacity: 0.6;
}
.attachment-row.is-removed .attachment-text strong {
text-decoration: line-through;
}
.attachment-queue-title {
font-size: 12px;
color: #64748b;
font-weight: 700;
padding: 8px 0 2px;
}
.custom-field-row {
grid-template-columns: minmax(110px, 220px) minmax(0, 1fr) auto;
}
@@ -933,6 +1032,79 @@ input[type='file'].input::file-selector-button:hover {
gap: 12px;
}
.import-export-page {
display: grid;
gap: 12px;
}
.import-export-hero {
margin-bottom: 0;
}
.import-export-hero h3 {
margin: 0 0 8px 0;
}
.import-export-hero-sub {
margin: 0;
color: #5f6f85;
line-height: 1.5;
}
.import-export-feature-grid {
margin-top: 12px;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.import-export-feature-item {
border: 1px solid #d9e4f2;
border-radius: 10px;
background: #f7faff;
padding: 10px;
display: flex;
align-items: flex-start;
gap: 10px;
min-width: 0;
}
.import-export-feature-icon {
width: 28px;
height: 28px;
border-radius: 8px;
border: 1px solid #cbdcf7;
background: #e9f1ff;
color: #1d4ed8;
display: inline-grid;
place-items: center;
flex-shrink: 0;
}
.import-export-feature-item strong {
display: block;
font-size: 14px;
line-height: 1.35;
}
.import-export-feature-item p {
margin: 4px 0 0 0;
color: #64748b;
font-size: 13px;
line-height: 1.45;
}
.import-export-panels {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
align-items: start;
}
.import-export-panel h3 {
margin: 0 0 6px 0;
}
.field-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -1225,6 +1397,64 @@ input[type='file'].input::file-selector-button:hover {
margin: 8px 0 10px;
}
.import-summary-dialog {
max-width: 520px;
text-align: left;
position: relative;
padding-top: 16px;
}
.import-summary-close {
position: absolute;
top: 10px;
right: 10px;
border: none;
background: transparent;
color: #64748b;
font-size: 24px;
line-height: 1;
cursor: pointer;
}
.import-summary-close:hover {
color: #0f172a;
}
.import-summary-table-wrap {
margin-top: 8px;
border: 1px solid var(--line);
border-radius: 10px;
overflow: hidden;
}
.import-summary-table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
.import-summary-table th,
.import-summary-table td {
padding: 10px 12px;
border-bottom: 1px solid var(--line);
}
.import-summary-table th {
text-align: left;
color: #475467;
background: #f8fafc;
}
.import-summary-table td:last-child,
.import-summary-table th:last-child {
text-align: right;
width: 96px;
}
.import-summary-table tbody tr:last-child td {
border-bottom: none;
}
.settings-twofactor-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -1353,6 +1583,11 @@ input[type='file'].input::file-selector-button:hover {
grid-template-columns: 1fr;
}
.import-export-feature-grid,
.import-export-panels {
grid-template-columns: 1fr;
}
.uri-row {
grid-template-columns: 1fr;
}
+3
View File
@@ -3,6 +3,9 @@ main = "src/index.ts"
compatibility_date = "2024-01-01"
assets = { directory = "./dist", not_found_handling = "single-page-application", run_worker_first = ["/api/*", "/identity/*", "/icons/*", "/setup/*", "/config", "/notifications/*", "/.well-known/*"] }
[build]
command = "npm run build"
# D1 Database for storing vault data
[[d1_databases]]
binding = "DB"