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.
This commit is contained in:
shuaiplus
2026-03-03 02:18:26 +08:00
parent 4da5525a1a
commit b5284e669a
10 changed files with 3359 additions and 25 deletions
@@ -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>
);
}
+378
View File
@@ -0,0 +1,378 @@
import { useState } from 'preact/hooks';
import { argon2idAsync } from '@noble/hashes/argon2.js';
import { strFromU8, unzipSync } from 'fflate';
import { FileUp } from 'lucide-preact';
import ConfirmDialog from '@/components/ConfirmDialog';
import type { CiphersImportPayload } from '@/lib/api';
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';
interface ImportPageProps {
onImport: (
payload: CiphersImportPayload,
options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null }
) => Promise<void>;
onImportEncryptedRaw: (
payload: CiphersImportPayload,
options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null }
) => Promise<void>;
accountKeys?: { encB64: string; macB64: string } | null;
onNotify: (type: 'success' | 'error', text: string) => void;
folders: Folder[];
}
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',
'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('Invalid password-protected export 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('Invalid password-protected export file.');
}
const pass = String(password || '').trim();
if (!pass) {
throw new Error('Please enter file password.');
}
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('Failed to decrypt import file.');
}
}
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('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('No importable JSON data found in zip archive.');
}
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);
}
export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys, onNotify, folders }: 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<BitwardenPasswordProtectedInput | null>(null);
const [folderMode, setFolderMode] = useState<'original' | 'none' | 'target'>('original');
const [targetFolderId, setTargetFolderId] = useState('');
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): Promise<void> {
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.');
}
await onImportEncryptedRaw(normalizeBitwardenEncryptedAccountImport(accountEncrypted), {
folderMode,
targetFolderId: folderMode === 'target' ? targetFolderId || null : null,
});
return;
}
await onImport(normalizeBitwardenImport(parsed), {
folderMode,
targetFolderId: folderMode === 'target' ? targetFolderId || null : null,
});
}
async function handleSubmit() {
if (!file) {
onNotify('error', t('txt_please_select_a_file'));
return;
}
setIsSubmitting(true);
try {
const text = await readImportText(file, source);
if (source === 'bitwarden_json') {
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
throw new Error('Invalid JSON file');
}
if (isPasswordProtectedExport(parsed)) {
setPendingPasswordImport(parsed);
setImportPassword('');
setPasswordDialogOpen(true);
return;
}
await runBitwardenJsonImport(parsed);
} else {
await onImport(parseImportPayloadBySource(source, text), {
folderMode,
targetFolderId: folderMode === 'target' ? targetFolderId || null : null,
});
}
setFile(null);
onNotify('success', 'Import completed');
} catch (error) {
const message = error instanceof Error ? error.message : 'Import failed';
onNotify('error', message);
} finally {
setIsSubmitting(false);
}
}
async function handlePasswordImportConfirm() {
if (!pendingPasswordImport) return;
setIsPasswordSubmitting(true);
try {
const parsed = await decryptPasswordProtectedExport(pendingPasswordImport, importPassword);
await runBitwardenJsonImport(parsed);
setFile(null);
setImportPassword('');
setPendingPasswordImport(null);
setPasswordDialogOpen(false);
onNotify('success', 'Import completed');
} catch (error) {
const message = error instanceof Error ? error.message : 'Import failed';
onNotify('error', message);
} finally {
setIsPasswordSubmitting(false);
}
}
return (
<div className="stack">
<section className="card">
<h3>Import</h3>
<p className="muted" style={{ textAlign: 'left', marginBottom: 12 }}>
Import vault data into your current account.
</p>
<div className="field-grid">
<label className="field field-span-2">
<span>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>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>Folder handling</span>
<select
className="input"
value={folderMode}
onChange={(e) => setFolderMode((e.currentTarget as HTMLSelectElement).value as 'original' | 'none' | 'target')}
>
<option value="original">Original path from import file</option>
<option value="none">No folder</option>
<option value="target">One selected folder</option>
</select>
</label>
{folderMode === 'target' && (
<label className="field field-span-2">
<span>Target folder</span>
<select className="input" value={targetFolderId} onChange={(e) => setTargetFolderId((e.currentTarget as HTMLSelectElement).value)}>
<option value="">-- Select folder --</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') : 'Import'}
</button>
</div>
</section>
<ConfirmDialog
open={passwordDialogOpen}
title="Import encrypted file"
message="This Bitwarden export is password-protected. Enter the export file password to continue."
confirmText={isPasswordSubmitting ? t('txt_loading') : 'Import'}
cancelText={t('txt_cancel')}
showIcon={false}
onConfirm={() => void handlePasswordImportConfirm()}
onCancel={() => {
if (isPasswordSubmitting) return;
setPasswordDialogOpen(false);
setImportPassword('');
setPendingPasswordImport(null);
}}
>
<label className="field">
<span>File password</span>
<input
className="input"
type="password"
value={importPassword}
onInput={(e) => setImportPassword((e.currentTarget as HTMLInputElement).value)}
/>
</label>
</ConfirmDialog>
</div>
);
}
+24
View File
@@ -158,6 +158,7 @@ function createEmptyDraft(type: number): VaultDraft {
loginPassword: '',
loginTotp: '',
loginUris: [''],
loginFido2Credentials: [],
cardholderName: '',
cardNumber: '',
cardBrand: '',
@@ -203,6 +204,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 +268,16 @@ function formatHistoryTime(value: string | null | undefined): string {
return date.toLocaleString();
}
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;
@@ -419,6 +433,7 @@ export default function VaultPage(props: VaultPageProps) {
() => props.ciphers.find((x) => x.id === selectedCipherId) || null,
[props.ciphers, selectedCipherId]
);
const passkeyCreatedAt = firstPasskeyCreationTime(selectedCipher);
useEffect(() => {
const raw = selectedCipher?.login?.decTotp || '';
@@ -1172,6 +1187,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>
)}