mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
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:
Generated
+21
@@ -9,7 +9,9 @@
|
|||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"license": "LGPL-3.0",
|
"license": "LGPL-3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@noble/hashes": "^2.0.1",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
|
"fflate": "^0.8.2",
|
||||||
"lucide-preact": "^0.575.0",
|
"lucide-preact": "^0.575.0",
|
||||||
"preact": "^10.28.4",
|
"preact": "^10.28.4",
|
||||||
"qrcode-generator": "^2.0.4",
|
"qrcode-generator": "^2.0.4",
|
||||||
@@ -1519,6 +1521,18 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.10"
|
"@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": {
|
"node_modules/@poppinss/colors": {
|
||||||
"version": "4.1.6",
|
"version": "4.1.6",
|
||||||
"resolved": "https://registry.npmmirror.com/@poppinss/colors/-/colors-4.1.6.tgz",
|
"resolved": "https://registry.npmmirror.com/@poppinss/colors/-/colors-4.1.6.tgz",
|
||||||
@@ -2413,6 +2427,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": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
@@ -3119,6 +3139,7 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"workerd": "bin/workerd"
|
"workerd": "bin/workerd"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -45,7 +45,9 @@
|
|||||||
"wrangler": "^4.69.0"
|
"wrangler": "^4.69.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@noble/hashes": "^2.0.1",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
|
"fflate": "^0.8.2",
|
||||||
"lucide-preact": "^0.575.0",
|
"lucide-preact": "^0.575.0",
|
||||||
"preact": "^10.28.4",
|
"preact": "^10.28.4",
|
||||||
"qrcode-generator": "^2.0.4",
|
"qrcode-generator": "^2.0.4",
|
||||||
|
|||||||
+298
-4
@@ -14,13 +14,14 @@ import SettingsPage from '@/components/SettingsPage';
|
|||||||
import SecurityDevicesPage from '@/components/SecurityDevicesPage';
|
import SecurityDevicesPage from '@/components/SecurityDevicesPage';
|
||||||
import AdminPage from '@/components/AdminPage';
|
import AdminPage from '@/components/AdminPage';
|
||||||
import HelpPage from '@/components/HelpPage';
|
import HelpPage from '@/components/HelpPage';
|
||||||
import ImportExportPage from '@/components/ImportExportPage';
|
import ImportPage from '@/components/ImportPage';
|
||||||
import {
|
import {
|
||||||
changeMasterPassword,
|
changeMasterPassword,
|
||||||
createFolder,
|
createFolder,
|
||||||
createCipher,
|
createCipher,
|
||||||
createAuthedFetch,
|
createAuthedFetch,
|
||||||
createInvite,
|
createInvite,
|
||||||
|
importCiphers,
|
||||||
createSend,
|
createSend,
|
||||||
deleteAllInvites,
|
deleteAllInvites,
|
||||||
deleteCipher,
|
deleteCipher,
|
||||||
@@ -58,6 +59,7 @@ import {
|
|||||||
} from '@/lib/api';
|
} from '@/lib/api';
|
||||||
import { base64ToBytes, decryptBw, decryptStr, hkdf } from '@/lib/crypto';
|
import { base64ToBytes, decryptBw, decryptStr, hkdf } from '@/lib/crypto';
|
||||||
import { t } from '@/lib/i18n';
|
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';
|
import type { AppPhase, AuthorizedDevice, Cipher, Folder, Profile, Send, SendDraft, SessionState, ToastMessage, VaultDraft } from '@/lib/types';
|
||||||
|
|
||||||
interface PendingTotp {
|
interface PendingTotp {
|
||||||
@@ -70,6 +72,135 @@ type JwtUnsafeReason = 'missing' | 'default' | 'too_short';
|
|||||||
|
|
||||||
const SEND_KEY_SALT = 'bitwarden-send';
|
const SEND_KEY_SALT = 'bitwarden-send';
|
||||||
const SEND_KEY_PURPOSE = '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 asText(value: unknown): string {
|
||||||
|
if (value === null || value === undefined) return '';
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
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.fingerprint);
|
||||||
|
}
|
||||||
|
|
||||||
|
return draft;
|
||||||
|
}
|
||||||
|
|
||||||
function buildPublicSendUrl(origin: string, accessId: string, keyPart: string): string {
|
function buildPublicSendUrl(origin: string, accessId: string, keyPart: string): string {
|
||||||
return `${origin}/#/send/${accessId}/${keyPart}`;
|
return `${origin}/#/send/${accessId}/${keyPart}`;
|
||||||
@@ -470,6 +601,9 @@ export default function App() {
|
|||||||
decUsername: await decryptField(cipher.login.username || '', itemEnc, itemMac),
|
decUsername: await decryptField(cipher.login.username || '', itemEnc, itemMac),
|
||||||
decPassword: await decryptField(cipher.login.password || '', itemEnc, itemMac),
|
decPassword: await decryptField(cipher.login.password || '', itemEnc, itemMac),
|
||||||
decTotp: await decryptField(cipher.login.totp || '', 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(
|
uris: await Promise.all(
|
||||||
(cipher.login.uris || []).map(async (u) => ({
|
(cipher.login.uris || []).map(async (u) => ({
|
||||||
...u,
|
...u,
|
||||||
@@ -818,17 +952,126 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleImportAction(
|
||||||
|
payload: CiphersImportPayload,
|
||||||
|
options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null }
|
||||||
|
) {
|
||||||
|
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>();
|
||||||
|
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, name);
|
||||||
|
folderId = created.id;
|
||||||
|
createdFolderIdByName.set(name, folderId);
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleImportEncryptedRawAction(
|
||||||
|
payload: CiphersImportPayload,
|
||||||
|
options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null }
|
||||||
|
) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
await importCiphers(authedFetch, nextPayload);
|
||||||
|
await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]);
|
||||||
|
}
|
||||||
|
|
||||||
const hashPathRaw = typeof window !== 'undefined' ? window.location.hash || '' : '';
|
const hashPathRaw = typeof window !== 'undefined' ? window.location.hash || '' : '';
|
||||||
const hashPath = hashPathRaw.startsWith('#') ? hashPathRaw.slice(1) : hashPathRaw;
|
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 effectiveLocation = hashPath.startsWith('/send/') || hashPath === '/recover-2fa' ? hashPath : location;
|
||||||
const publicSendMatch = effectiveLocation.match(/^\/send\/([^/]+)(?:\/([^/]+))?\/?$/i);
|
const publicSendMatch = effectiveLocation.match(/^\/send\/([^/]+)(?:\/([^/]+))?\/?$/i);
|
||||||
const isRecoverTwoFactorRoute = effectiveLocation === '/recover-2fa';
|
const isRecoverTwoFactorRoute = effectiveLocation === '/recover-2fa';
|
||||||
const isPublicSendRoute = !!publicSendMatch;
|
const isPublicSendRoute = !!publicSendMatch;
|
||||||
|
const isImportRoute = location === IMPORT_ROUTE || IMPORT_ROUTE_ALIASES.has(location);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (phase === 'app' && location === '/' && !isPublicSendRoute) navigate('/vault');
|
if (phase === 'app' && location === '/' && !isPublicSendRoute) navigate('/vault');
|
||||||
}, [phase, location, isPublicSendRoute, navigate]);
|
}, [phase, location, isPublicSendRoute, navigate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (phase === 'app' && isImportHashRoute && location !== IMPORT_ROUTE) {
|
||||||
|
navigate(IMPORT_ROUTE);
|
||||||
|
}
|
||||||
|
}, [phase, isImportHashRoute, location, navigate]);
|
||||||
|
|
||||||
if (jwtWarning) {
|
if (jwtWarning) {
|
||||||
return <JwtWarningPage reason={jwtWarning.reason} minLength={jwtWarning.minLength} />;
|
return <JwtWarningPage reason={jwtWarning.reason} minLength={jwtWarning.minLength} />;
|
||||||
}
|
}
|
||||||
@@ -984,7 +1227,7 @@ export default function App() {
|
|||||||
<Cloud size={16} />
|
<Cloud size={16} />
|
||||||
<span>{t('nav_backup_strategy')}</span>
|
<span>{t('nav_backup_strategy')}</span>
|
||||||
</Link>
|
</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} />
|
<ArrowUpDown size={14} />
|
||||||
<span>{t('nav_import_export')}</span>
|
<span>{t('nav_import_export')}</span>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -1132,8 +1375,59 @@ export default function App() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/help/import-export">
|
<Route path={IMPORT_ROUTE}>
|
||||||
<ImportExportPage />
|
<ImportPage
|
||||||
|
onImport={handleImportAction}
|
||||||
|
onImportEncryptedRaw={handleImportEncryptedRawAction}
|
||||||
|
accountKeys={session?.symEncKey && session?.symMacKey ? { encB64: session.symEncKey, macB64: session.symMacKey } : null}
|
||||||
|
onNotify={pushToast}
|
||||||
|
folders={decryptedFolders}
|
||||||
|
/>
|
||||||
|
</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}
|
||||||
|
/>
|
||||||
|
</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}
|
||||||
|
/>
|
||||||
|
</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}
|
||||||
|
/>
|
||||||
|
</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}
|
||||||
|
/>
|
||||||
|
</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}
|
||||||
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/help">
|
<Route path="/help">
|
||||||
<HelpPage />
|
<HelpPage />
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -158,6 +158,7 @@ function createEmptyDraft(type: number): VaultDraft {
|
|||||||
loginPassword: '',
|
loginPassword: '',
|
||||||
loginTotp: '',
|
loginTotp: '',
|
||||||
loginUris: [''],
|
loginUris: [''],
|
||||||
|
loginFido2Credentials: [],
|
||||||
cardholderName: '',
|
cardholderName: '',
|
||||||
cardNumber: '',
|
cardNumber: '',
|
||||||
cardBrand: '',
|
cardBrand: '',
|
||||||
@@ -203,6 +204,9 @@ function draftFromCipher(cipher: Cipher): VaultDraft {
|
|||||||
draft.loginPassword = cipher.login.decPassword || '';
|
draft.loginPassword = cipher.login.decPassword || '';
|
||||||
draft.loginTotp = cipher.login.decTotp || '';
|
draft.loginTotp = cipher.login.decTotp || '';
|
||||||
draft.loginUris = (cipher.login.uris || []).map((x) => x.decUri || x.uri || '');
|
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 (!draft.loginUris.length) draft.loginUris = [''];
|
||||||
}
|
}
|
||||||
if (cipher.card) {
|
if (cipher.card) {
|
||||||
@@ -264,6 +268,16 @@ function formatHistoryTime(value: string | null | undefined): string {
|
|||||||
return date.toLocaleString();
|
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_PERIOD_SECONDS = 30;
|
||||||
const TOTP_RING_RADIUS = 14;
|
const TOTP_RING_RADIUS = 14;
|
||||||
const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS;
|
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.find((x) => x.id === selectedCipherId) || null,
|
||||||
[props.ciphers, selectedCipherId]
|
[props.ciphers, selectedCipherId]
|
||||||
);
|
);
|
||||||
|
const passkeyCreatedAt = firstPasskeyCreationTime(selectedCipher);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const raw = selectedCipher?.login?.decTotp || '';
|
const raw = selectedCipher?.login?.decTotp || '';
|
||||||
@@ -1172,6 +1187,15 @@ function folderName(id: string | null | undefined): string {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
+76
-2
@@ -307,13 +307,16 @@ export async function getFolders(authedFetch: (input: string, init?: RequestInit
|
|||||||
export async function createFolder(
|
export async function createFolder(
|
||||||
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
|
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
|
||||||
name: string
|
name: string
|
||||||
): Promise<void> {
|
): Promise<{ id: string; name?: string | null }> {
|
||||||
const resp = await authedFetch('/api/folders', {
|
const resp = await authedFetch('/api/folders', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ name }),
|
body: JSON.stringify({ name }),
|
||||||
});
|
});
|
||||||
if (!resp.ok) throw new Error('Create folder failed');
|
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 getCiphers(authedFetch: (input: string, init?: RequestInit) => Promise<Response>): Promise<Cipher[]> {
|
export async function getCiphers(authedFetch: (input: string, init?: RequestInit) => Promise<Response>): Promise<Cipher[]> {
|
||||||
@@ -323,6 +326,24 @@ export async function getCiphers(authedFetch: (input: string, init?: RequestInit
|
|||||||
return body?.data || [];
|
return body?.data || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CiphersImportPayload {
|
||||||
|
ciphers: Array<Record<string, unknown>>;
|
||||||
|
folders: Array<{ name: string }>;
|
||||||
|
folderRelationships: Array<{ key: number; value: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importCiphers(
|
||||||
|
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
|
||||||
|
payload: CiphersImportPayload
|
||||||
|
): Promise<void> {
|
||||||
|
const resp = await authedFetch('/api/ciphers/import', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Import failed'));
|
||||||
|
}
|
||||||
|
|
||||||
export async function getSends(authedFetch: (input: string, init?: RequestInit) => Promise<Response>): Promise<Send[]> {
|
export async function getSends(authedFetch: (input: string, init?: RequestInit) => Promise<Response>): Promise<Send[]> {
|
||||||
const resp = await authedFetch('/api/sends');
|
const resp = await authedFetch('/api/sends');
|
||||||
if (!resp.ok) throw new Error('Failed to load sends');
|
if (!resp.ok) throw new Error('Failed to load sends');
|
||||||
@@ -571,6 +592,50 @@ async function encryptUris(uris: string[], enc: Uint8Array, mac: Uint8Array): Pr
|
|||||||
return out;
|
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 }> {
|
async function getCipherKeys(cipher: Cipher | null, userEnc: Uint8Array, userMac: Uint8Array): Promise<{ enc: Uint8Array; mac: Uint8Array; key: string | null }> {
|
||||||
if (cipher?.key) {
|
if (cipher?.key) {
|
||||||
try {
|
try {
|
||||||
@@ -587,7 +652,7 @@ export async function createCipher(
|
|||||||
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
|
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
|
||||||
session: SessionState,
|
session: SessionState,
|
||||||
draft: VaultDraft
|
draft: VaultDraft
|
||||||
): Promise<void> {
|
): Promise<{ id: string }> {
|
||||||
if (!session.symEncKey || !session.symMacKey) throw new Error('Vault key unavailable');
|
if (!session.symEncKey || !session.symMacKey) throw new Error('Vault key unavailable');
|
||||||
const enc = base64ToBytes(session.symEncKey);
|
const enc = base64ToBytes(session.symEncKey);
|
||||||
const mac = base64ToBytes(session.symMacKey);
|
const mac = base64ToBytes(session.symMacKey);
|
||||||
@@ -613,6 +678,7 @@ export async function createCipher(
|
|||||||
username: await encryptTextValue(draft.loginUsername, enc, mac),
|
username: await encryptTextValue(draft.loginUsername, enc, mac),
|
||||||
password: await encryptTextValue(draft.loginPassword, enc, mac),
|
password: await encryptTextValue(draft.loginPassword, enc, mac),
|
||||||
totp: await encryptTextValue(draft.loginTotp, enc, mac),
|
totp: await encryptTextValue(draft.loginTotp, enc, mac),
|
||||||
|
fido2Credentials: normalizeFido2Credentials(draft.loginFido2Credentials),
|
||||||
uris: await encryptUris(draft.loginUris || [], enc, mac),
|
uris: await encryptUris(draft.loginUris || [], enc, mac),
|
||||||
};
|
};
|
||||||
} else if (type === 3) {
|
} else if (type === 3) {
|
||||||
@@ -661,6 +727,9 @@ export async function createCipher(
|
|||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
if (!resp.ok) throw new Error('Create item failed');
|
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(
|
export async function updateCipher(
|
||||||
@@ -693,10 +762,15 @@ export async function updateCipher(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (type === 1) {
|
if (type === 1) {
|
||||||
|
const existingFido2 =
|
||||||
|
cipher.login && Array.isArray((cipher.login as any).fido2Credentials)
|
||||||
|
? (cipher.login as any).fido2Credentials
|
||||||
|
: null;
|
||||||
payload.login = {
|
payload.login = {
|
||||||
username: await encryptTextValue(draft.loginUsername, keys.enc, keys.mac),
|
username: await encryptTextValue(draft.loginUsername, keys.enc, keys.mac),
|
||||||
password: await encryptTextValue(draft.loginPassword, keys.enc, keys.mac),
|
password: await encryptTextValue(draft.loginPassword, keys.enc, keys.mac),
|
||||||
totp: await encryptTextValue(draft.loginTotp, 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),
|
uris: await encryptUris(draft.loginUris || [], keys.enc, keys.mac),
|
||||||
};
|
};
|
||||||
} else if (type === 3) {
|
} else if (type === 3) {
|
||||||
|
|||||||
@@ -326,6 +326,8 @@ const messages: Record<Locale, Record<string, string>> = {
|
|||||||
txt_totp_is_enabled_for_this_account: "TOTP is enabled for this account.",
|
txt_totp_is_enabled_for_this_account: "TOTP is enabled for this account.",
|
||||||
txt_totp_secret: "TOTP Secret",
|
txt_totp_secret: "TOTP Secret",
|
||||||
txt_totp_verify_failed: "TOTP verify failed",
|
txt_totp_verify_failed: "TOTP verify failed",
|
||||||
|
txt_passkey: "Passkey",
|
||||||
|
txt_passkey_created_at_value: "Created at {value}",
|
||||||
txt_trash: "Trash",
|
txt_trash: "Trash",
|
||||||
txt_trust_this_device_for_30_days: "Trust this device for 30 days",
|
txt_trust_this_device_for_30_days: "Trust this device for 30 days",
|
||||||
txt_trusted_until: "Trusted Until",
|
txt_trusted_until: "Trusted Until",
|
||||||
@@ -728,6 +730,8 @@ const zhCNOverrides: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
zhCNOverrides.txt_lock = '\u9501\u5b9a';
|
zhCNOverrides.txt_lock = '\u9501\u5b9a';
|
||||||
|
zhCNOverrides.txt_passkey = 'Passkey';
|
||||||
|
zhCNOverrides.txt_passkey_created_at_value = '\u521b\u5efa\u4e8e {value}';
|
||||||
|
|
||||||
messages['zh-CN'] = { ...messages.en, ...zhCNOverrides };
|
messages['zh-CN'] = { ...messages.en, ...zhCNOverrides };
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -28,11 +28,17 @@ export interface CipherLoginUri {
|
|||||||
decUri?: string;
|
decUri?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CipherLoginPasskey {
|
||||||
|
creationDate?: string | null;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CipherLogin {
|
export interface CipherLogin {
|
||||||
username?: string | null;
|
username?: string | null;
|
||||||
password?: string | null;
|
password?: string | null;
|
||||||
totp?: string | null;
|
totp?: string | null;
|
||||||
uris?: CipherLoginUri[] | null;
|
uris?: CipherLoginUri[] | null;
|
||||||
|
fido2Credentials?: CipherLoginPasskey[] | null;
|
||||||
decUsername?: string;
|
decUsername?: string;
|
||||||
decPassword?: string;
|
decPassword?: string;
|
||||||
decTotp?: string;
|
decTotp?: string;
|
||||||
@@ -196,6 +202,7 @@ export interface VaultDraft {
|
|||||||
loginPassword: string;
|
loginPassword: string;
|
||||||
loginTotp: string;
|
loginTotp: string;
|
||||||
loginUris: string[];
|
loginUris: string[];
|
||||||
|
loginFido2Credentials: Array<Record<string, unknown>>;
|
||||||
cardholderName: string;
|
cardholderName: string;
|
||||||
cardNumber: string;
|
cardNumber: string;
|
||||||
cardBrand: string;
|
cardBrand: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user