mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
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.
This commit is contained in:
+225
-3
@@ -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,
|
||||
@@ -369,16 +394,213 @@ export interface CiphersImportPayload {
|
||||
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
|
||||
): Promise<void> {
|
||||
const resp = await authedFetch('/api/ciphers/import', {
|
||||
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[]> {
|
||||
|
||||
@@ -0,0 +1,694 @@
|
||||
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,
|
||||
fingerprint: 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;
|
||||
out.sshKey = cipher.sshKey ? await deepDecryptUnknown(cipher.sshKey, keyParts.enc, keyParts.mac) : 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);
|
||||
}
|
||||
+87
-2
@@ -328,6 +328,10 @@ const messages: Record<Locale, Record<string, string>> = {
|
||||
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",
|
||||
@@ -729,9 +733,90 @@ 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 = '\u521b\u5efa\u4e8e {value}';
|
||||
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_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 密码继续。';
|
||||
|
||||
messages['zh-CN'] = { ...messages.en, ...zhCNOverrides };
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ type ImportSourceEntry = { id: string; label: string };
|
||||
export const IMPORT_SOURCES = [
|
||||
{ id: 'bitwarden_json', label: 'Bitwarden (json)' },
|
||||
{ id: 'bitwarden_csv', label: 'Bitwarden (csv)' },
|
||||
{ id: 'bitwarden_zip', label: 'Bitwarden (zip)' },
|
||||
{ id: 'nodewarden_json', label: 'NodeWarden (json)' },
|
||||
{ id: 'onepassword_1pux', label: '1Password (1pux/json)' },
|
||||
{ id: 'onepassword_1pif', label: '1Password (1pif)' },
|
||||
{ id: 'onepassword_mac_csv', label: '1Password 6 and 7 Mac (csv)' },
|
||||
@@ -53,8 +55,10 @@ export const IMPORT_SOURCES = [
|
||||
export type ImportSourceId = (typeof IMPORT_SOURCES)[number]['id'];
|
||||
|
||||
export function getFileAcceptBySource(source: ImportSourceId): string {
|
||||
if (source === 'bitwarden_zip') return '.zip,application/zip,application/x-zip-compressed';
|
||||
if (
|
||||
source === 'bitwarden_json' ||
|
||||
source === 'nodewarden_json' ||
|
||||
source === 'onepassword_1pux' ||
|
||||
source === 'protonpass_json' ||
|
||||
source === 'avast_json' ||
|
||||
@@ -90,6 +94,7 @@ export interface BitwardenFieldInput {
|
||||
linkedId?: number | null;
|
||||
}
|
||||
export interface BitwardenCipherInput {
|
||||
id?: string | null;
|
||||
type?: number | null;
|
||||
name?: string | null;
|
||||
notes?: string | null;
|
||||
@@ -2415,6 +2420,7 @@ export function normalizeBitwardenImport(raw: unknown): CiphersImportPayload {
|
||||
let hasAnyExplicitFolderLink = false;
|
||||
for (const item of itemsRaw) {
|
||||
ciphers.push({
|
||||
id: item?.id ?? null,
|
||||
type: Number(item?.type || 1) || 1,
|
||||
name: item?.name ?? 'Untitled',
|
||||
notes: item?.notes ?? null,
|
||||
@@ -2498,6 +2504,12 @@ const IMPORT_SOURCE_PARSERS: Record<ImportSourceId, (textRaw: string) => Ciphers
|
||||
bitwarden_json: () => {
|
||||
throw new Error('bitwarden_json is handled by dedicated JSON flow');
|
||||
},
|
||||
bitwarden_zip: () => {
|
||||
throw new Error('bitwarden_zip is handled by dedicated zip flow');
|
||||
},
|
||||
nodewarden_json: () => {
|
||||
throw new Error('nodewarden_json is handled by dedicated JSON flow');
|
||||
},
|
||||
bitwarden_csv: parseBitwardenCsv,
|
||||
onepassword_1pux: parseOnePassword1PuxJson,
|
||||
onepassword_1pif: parseOnePassword1Pif,
|
||||
|
||||
@@ -28,6 +28,17 @@ 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;
|
||||
@@ -111,6 +122,7 @@ export interface CipherField {
|
||||
type?: number | string | null;
|
||||
name?: string | null;
|
||||
value?: string | null;
|
||||
linkedId?: number | null;
|
||||
decName?: string;
|
||||
decValue?: string;
|
||||
}
|
||||
@@ -127,10 +139,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;
|
||||
|
||||
Reference in New Issue
Block a user