From aa6f9210b4c5ef5b7935f1b182758ed3d5239514 Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Tue, 28 Apr 2026 00:34:52 +0800 Subject: [PATCH] feat: implement cipher decryption functionality and update related API methods --- webapp/src/App.tsx | 1 + webapp/src/hooks/useVaultSendActions.ts | 49 ++++++++-- webapp/src/lib/api/vault.ts | 18 ++-- webapp/src/lib/decrypt-cipher.ts | 123 ++++++++++++++++++++++++ 4 files changed, 175 insertions(+), 16 deletions(-) create mode 100644 webapp/src/lib/decrypt-cipher.ts diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index 86c2a01..adfe1e1 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -1282,6 +1282,7 @@ export default function App() { refetchFolders: foldersQuery.refetch, refetchSends: sendsQuery.refetch, onNotify: pushToast, + patchDecryptedCiphers: setDecryptedCiphers, }); const accountSecurityActions = useAccountSecurityActions({ authedFetch, diff --git a/webapp/src/hooks/useVaultSendActions.ts b/webapp/src/hooks/useVaultSendActions.ts index 139056e..ca3b338 100644 --- a/webapp/src/hooks/useVaultSendActions.ts +++ b/webapp/src/hooks/useVaultSendActions.ts @@ -13,6 +13,7 @@ import { encryptZipBytesWithPassword, } from '@/lib/export-formats'; import { base64ToBytes, decryptBw, decryptBwFileData, decryptStr } from '@/lib/crypto'; +import { decryptSingleCipher } from '@/lib/decrypt-cipher'; import { t } from '@/lib/i18n'; import { buildPublicSendUrl, @@ -66,6 +67,7 @@ interface UseVaultSendActionsOptions { refetchFolders: () => Promise<{ data?: VaultFolder[] | undefined } | unknown>; refetchSends: () => Promise; onNotify: Notify; + patchDecryptedCiphers: (updater: (prev: Cipher[]) => Cipher[]) => void; } function extractImportIdMaps(cipherMap: ImportedCipherMapEntry[] | null) { @@ -95,6 +97,7 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) refetchFolders, refetchSends, onNotify, + patchDecryptedCiphers, } = options; const [downloadingAttachmentKey, setDownloadingAttachmentKey] = useState(''); const [attachmentDownloadPercent, setAttachmentDownloadPercent] = useState(null); @@ -108,6 +111,29 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) await Promise.all([refetchCiphers(), refetchFolders(), refetchSends()]); }; + async function decryptAndPatch(encrypted: Cipher) { + if (!session?.symEncKey || !session?.symMacKey) { + await refetchCiphers(); + return; + } + const encKey = base64ToBytes(session.symEncKey); + const macKey = base64ToBytes(session.symMacKey); + const decrypted = await decryptSingleCipher(encrypted, encKey, macKey); + patchDecryptedCiphers((prev) => { + const idx = prev.findIndex((c) => c.id === decrypted.id); + if (idx >= 0) { + const next = [...prev]; + next[idx] = decrypted; + return next; + } + return [decrypted, ...prev]; + }); + } + + function removeCipherFromState(id: string) { + patchDecryptedCiphers((prev) => prev.filter((c) => c.id !== id)); + } + const uploadImportedAttachments = async ( attachments: ImportAttachmentFile[], idMaps: { byIndex: Map; bySourceId: Map } @@ -175,7 +201,8 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) setAttachmentUploadPercent(0); await uploadCipherAttachment(authedFetch, session, created.id, file, undefined, setAttachmentUploadPercent); } - await Promise.all([refetchCiphers(), refetchFolders()]); + await decryptAndPatch(created); + if (draft.folderId) await refetchFolders(); onNotify('success', t('txt_item_created')); } catch (error) { onNotify('error', error instanceof Error ? error.message : t('txt_create_item_failed')); @@ -191,7 +218,7 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) const addFiles = Array.isArray(options?.addFiles) ? options.addFiles : []; const removeAttachmentIds = Array.isArray(options?.removeAttachmentIds) ? options.removeAttachmentIds : []; try { - await updateCipher(authedFetch, session, cipher, draft); + const updated = await updateCipher(authedFetch, session, cipher, draft); for (const attachmentId of removeAttachmentIds) { const id = String(attachmentId || '').trim(); if (!id) continue; @@ -202,7 +229,8 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) setAttachmentUploadPercent(0); await uploadCipherAttachment(authedFetch, session, cipher.id, file, cipher, setAttachmentUploadPercent); } - await Promise.all([refetchCiphers(), refetchFolders()]); + await decryptAndPatch(updated); + if (draft.folderId !== (cipher.folderId || '')) await refetchFolders(); onNotify('success', t('txt_item_updated')); } catch (error) { onNotify('error', error instanceof Error ? error.message : t('txt_update_item_failed')); @@ -233,8 +261,9 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) async deleteVaultItem(cipher: Cipher) { try { - await deleteCipher(authedFetch, cipher.id); - await Promise.all([refetchCiphers(), refetchFolders()]); + const deleted = await deleteCipher(authedFetch, cipher.id); + await decryptAndPatch(deleted); + await refetchFolders(); onNotify('success', t('txt_item_deleted')); } catch (error) { onNotify('error', error instanceof Error ? error.message : t('txt_delete_item_failed')); @@ -244,8 +273,9 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) async archiveVaultItem(cipher: Cipher) { try { - await archiveCipher(authedFetch, cipher.id); - await Promise.all([refetchCiphers(), refetchFolders()]); + const archived = await archiveCipher(authedFetch, cipher.id); + await decryptAndPatch(archived); + await refetchFolders(); onNotify('success', t('txt_item_archived')); } catch (error) { onNotify('error', error instanceof Error ? error.message : t('txt_archive_item_failed')); @@ -255,8 +285,9 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) async unarchiveVaultItem(cipher: Cipher) { try { - await unarchiveCipher(authedFetch, cipher.id); - await Promise.all([refetchCiphers(), refetchFolders()]); + const unarchived = await unarchiveCipher(authedFetch, cipher.id); + await decryptAndPatch(unarchived); + await refetchFolders(); onNotify('success', t('txt_item_unarchived')); } catch (error) { onNotify('error', error instanceof Error ? error.message : t('txt_unarchive_item_failed')); diff --git a/webapp/src/lib/api/vault.ts b/webapp/src/lib/api/vault.ts index 9b09c29..d97c7a0 100644 --- a/webapp/src/lib/api/vault.ts +++ b/webapp/src/lib/api/vault.ts @@ -766,7 +766,7 @@ export async function createCipher( authedFetch: AuthedFetch, session: SessionState, draft: VaultDraft -): Promise<{ id: string }> { +): Promise { const payload = await buildCipherPayload(session, draft, null); const resp = await authedFetch('/api/ciphers', { @@ -775,9 +775,9 @@ export async function createCipher( body: JSON.stringify(payload), }); if (!resp.ok) throw new Error('Create item failed'); - const body = await parseJson<{ id?: string }>(resp); + const body = await parseJson(resp); if (!body?.id) throw new Error('Create item failed'); - return { id: body.id }; + return body; } export async function updateCipher( @@ -785,7 +785,7 @@ export async function updateCipher( session: SessionState, cipher: Cipher, draft: VaultDraft -): Promise { +): Promise { const payload = await buildCipherPayload(session, draft, cipher); const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(cipher.id)}`, { @@ -794,25 +794,29 @@ export async function updateCipher( body: JSON.stringify(payload), }); if (!resp.ok) throw new Error('Update item failed'); + return (await parseJson(resp))!; } -export async function deleteCipher(authedFetch: AuthedFetch, cipherId: string): Promise { +export async function deleteCipher(authedFetch: AuthedFetch, cipherId: string): Promise { const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(cipherId)}`, { method: 'DELETE' }); if (!resp.ok) throw new Error('Delete item failed'); + return (await parseJson(resp))!; } -export async function archiveCipher(authedFetch: AuthedFetch, cipherId: string): Promise { +export async function archiveCipher(authedFetch: AuthedFetch, cipherId: string): Promise { const id = String(cipherId || '').trim(); if (!id) throw new Error('Cipher id is required'); const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(id)}/archive`, { method: 'PUT' }); if (!resp.ok) throw new Error('Archive item failed'); + return (await parseJson(resp))!; } -export async function unarchiveCipher(authedFetch: AuthedFetch, cipherId: string): Promise { +export async function unarchiveCipher(authedFetch: AuthedFetch, cipherId: string): Promise { const id = String(cipherId || '').trim(); if (!id) throw new Error('Cipher id is required'); const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(id)}/unarchive`, { method: 'PUT' }); if (!resp.ok) throw new Error('Unarchive item failed'); + return (await parseJson(resp))!; } export async function bulkDeleteCiphers(authedFetch: AuthedFetch, ids: string[]): Promise { diff --git a/webapp/src/lib/decrypt-cipher.ts b/webapp/src/lib/decrypt-cipher.ts new file mode 100644 index 0000000..c9de4f0 --- /dev/null +++ b/webapp/src/lib/decrypt-cipher.ts @@ -0,0 +1,123 @@ +import { decryptStr, decryptBw } from './crypto'; +import type { Cipher } from './types'; + +function sameBytes(a: Uint8Array, b: Uint8Array): boolean { + if (a.byteLength !== b.byteLength) return false; + for (let i = 0; i < a.byteLength; i += 1) { + if (a[i] !== b[i]) return false; + } + return true; +} + +async function decryptField( + value: string | null | undefined, + enc: Uint8Array, + mac: Uint8Array, +): Promise { + if (!value || typeof value !== 'string') return ''; + try { return await decryptStr(value, enc, mac); } catch { return value; } +} + +export async function decryptSingleCipher( + encrypted: Cipher, + userEnc: Uint8Array, + userMac: Uint8Array, +): Promise { + let itemEnc = userEnc; + let itemMac = userMac; + if (encrypted.key) { + try { + const itemKey = await decryptBw(encrypted.key, userEnc, userMac); + itemEnc = itemKey.slice(0, 32); + itemMac = itemKey.slice(32, 64); + } catch { /* keep user key */ } + } + + const decrypted: Cipher = { + ...encrypted, + decName: await decryptField(encrypted.name, itemEnc, itemMac), + decNotes: await decryptField(encrypted.notes, itemEnc, itemMac), + }; + + if (encrypted.login) { + decrypted.login = { + ...encrypted.login, + decUsername: await decryptField(encrypted.login.username, itemEnc, itemMac), + decPassword: await decryptField(encrypted.login.password, itemEnc, itemMac), + decTotp: await decryptField(encrypted.login.totp, itemEnc, itemMac), + uris: await Promise.all((encrypted.login.uris || []).map(async (u) => ({ + ...u, + decUri: await decryptField(u.uri, itemEnc, itemMac), + }))), + }; + } + + if (Array.isArray(encrypted.passwordHistory)) { + decrypted.passwordHistory = await Promise.all( + encrypted.passwordHistory.map(async (entry) => ({ + ...entry, + decPassword: await decryptField(entry?.password, itemEnc, itemMac), + })) + ); + } + + if (encrypted.card) { + decrypted.card = { + ...encrypted.card, + decCardholderName: await decryptField(encrypted.card.cardholderName, itemEnc, itemMac), + decNumber: await decryptField(encrypted.card.number, itemEnc, itemMac), + decBrand: await decryptField(encrypted.card.brand, itemEnc, itemMac), + decExpMonth: await decryptField(encrypted.card.expMonth, itemEnc, itemMac), + decExpYear: await decryptField(encrypted.card.expYear, itemEnc, itemMac), + decCode: await decryptField(encrypted.card.code, itemEnc, itemMac), + }; + } + + if (encrypted.identity) { + decrypted.identity = { + ...encrypted.identity, + decTitle: await decryptField(encrypted.identity.title, itemEnc, itemMac), + decFirstName: await decryptField(encrypted.identity.firstName, itemEnc, itemMac), + decMiddleName: await decryptField(encrypted.identity.middleName, itemEnc, itemMac), + decLastName: await decryptField(encrypted.identity.lastName, itemEnc, itemMac), + decUsername: await decryptField(encrypted.identity.username, itemEnc, itemMac), + decCompany: await decryptField(encrypted.identity.company, itemEnc, itemMac), + decSsn: await decryptField(encrypted.identity.ssn, itemEnc, itemMac), + decPassportNumber: await decryptField(encrypted.identity.passportNumber, itemEnc, itemMac), + decLicenseNumber: await decryptField(encrypted.identity.licenseNumber, itemEnc, itemMac), + decEmail: await decryptField(encrypted.identity.email, itemEnc, itemMac), + decPhone: await decryptField(encrypted.identity.phone, itemEnc, itemMac), + decAddress1: await decryptField(encrypted.identity.address1, itemEnc, itemMac), + decAddress2: await decryptField(encrypted.identity.address2, itemEnc, itemMac), + decAddress3: await decryptField(encrypted.identity.address3, itemEnc, itemMac), + decCity: await decryptField(encrypted.identity.city, itemEnc, itemMac), + decState: await decryptField(encrypted.identity.state, itemEnc, itemMac), + decPostalCode: await decryptField(encrypted.identity.postalCode, itemEnc, itemMac), + decCountry: await decryptField(encrypted.identity.country, itemEnc, itemMac), + }; + } + + if (encrypted.sshKey) { + const fingerprint = encrypted.sshKey.keyFingerprint || encrypted.sshKey.fingerprint || ''; + decrypted.sshKey = { + ...encrypted.sshKey, + decPrivateKey: await decryptField(encrypted.sshKey.privateKey, itemEnc, itemMac), + decPublicKey: await decryptField(encrypted.sshKey.publicKey, itemEnc, itemMac), + keyFingerprint: fingerprint || null, + fingerprint: fingerprint || null, + decFingerprint: await decryptField(fingerprint, itemEnc, itemMac), + }; + } + + if (encrypted.fields) { + decrypted.fields = await Promise.all( + encrypted.fields.map(async (field) => ({ + ...field, + decName: await decryptField(field.name, itemEnc, itemMac), + decValue: await decryptField(field.value, itemEnc, itemMac), + })) + ); + } + + return decrypted; +}