feat: implement cipher decryption functionality and update related API methods

This commit is contained in:
shuaiplus
2026-04-28 00:34:52 +08:00
parent 3be6a16d90
commit aa6f9210b4
4 changed files with 175 additions and 16 deletions
+1
View File
@@ -1282,6 +1282,7 @@ export default function App() {
refetchFolders: foldersQuery.refetch, refetchFolders: foldersQuery.refetch,
refetchSends: sendsQuery.refetch, refetchSends: sendsQuery.refetch,
onNotify: pushToast, onNotify: pushToast,
patchDecryptedCiphers: setDecryptedCiphers,
}); });
const accountSecurityActions = useAccountSecurityActions({ const accountSecurityActions = useAccountSecurityActions({
authedFetch, authedFetch,
+40 -9
View File
@@ -13,6 +13,7 @@ import {
encryptZipBytesWithPassword, encryptZipBytesWithPassword,
} from '@/lib/export-formats'; } from '@/lib/export-formats';
import { base64ToBytes, decryptBw, decryptBwFileData, decryptStr } from '@/lib/crypto'; import { base64ToBytes, decryptBw, decryptBwFileData, decryptStr } from '@/lib/crypto';
import { decryptSingleCipher } from '@/lib/decrypt-cipher';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
import { import {
buildPublicSendUrl, buildPublicSendUrl,
@@ -66,6 +67,7 @@ interface UseVaultSendActionsOptions {
refetchFolders: () => Promise<{ data?: VaultFolder[] | undefined } | unknown>; refetchFolders: () => Promise<{ data?: VaultFolder[] | undefined } | unknown>;
refetchSends: () => Promise<unknown>; refetchSends: () => Promise<unknown>;
onNotify: Notify; onNotify: Notify;
patchDecryptedCiphers: (updater: (prev: Cipher[]) => Cipher[]) => void;
} }
function extractImportIdMaps(cipherMap: ImportedCipherMapEntry[] | null) { function extractImportIdMaps(cipherMap: ImportedCipherMapEntry[] | null) {
@@ -95,6 +97,7 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
refetchFolders, refetchFolders,
refetchSends, refetchSends,
onNotify, onNotify,
patchDecryptedCiphers,
} = options; } = options;
const [downloadingAttachmentKey, setDownloadingAttachmentKey] = useState(''); const [downloadingAttachmentKey, setDownloadingAttachmentKey] = useState('');
const [attachmentDownloadPercent, setAttachmentDownloadPercent] = useState<number | null>(null); const [attachmentDownloadPercent, setAttachmentDownloadPercent] = useState<number | null>(null);
@@ -108,6 +111,29 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
await Promise.all([refetchCiphers(), refetchFolders(), refetchSends()]); 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 ( const uploadImportedAttachments = async (
attachments: ImportAttachmentFile[], attachments: ImportAttachmentFile[],
idMaps: { byIndex: Map<number, string>; bySourceId: Map<string, string> } idMaps: { byIndex: Map<number, string>; bySourceId: Map<string, string> }
@@ -175,7 +201,8 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
setAttachmentUploadPercent(0); setAttachmentUploadPercent(0);
await uploadCipherAttachment(authedFetch, session, created.id, file, undefined, setAttachmentUploadPercent); 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')); onNotify('success', t('txt_item_created'));
} catch (error) { } catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_create_item_failed')); 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 addFiles = Array.isArray(options?.addFiles) ? options.addFiles : [];
const removeAttachmentIds = Array.isArray(options?.removeAttachmentIds) ? options.removeAttachmentIds : []; const removeAttachmentIds = Array.isArray(options?.removeAttachmentIds) ? options.removeAttachmentIds : [];
try { try {
await updateCipher(authedFetch, session, cipher, draft); const updated = await updateCipher(authedFetch, session, cipher, draft);
for (const attachmentId of removeAttachmentIds) { for (const attachmentId of removeAttachmentIds) {
const id = String(attachmentId || '').trim(); const id = String(attachmentId || '').trim();
if (!id) continue; if (!id) continue;
@@ -202,7 +229,8 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
setAttachmentUploadPercent(0); setAttachmentUploadPercent(0);
await uploadCipherAttachment(authedFetch, session, cipher.id, file, cipher, setAttachmentUploadPercent); 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')); onNotify('success', t('txt_item_updated'));
} catch (error) { } catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_update_item_failed')); 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) { async deleteVaultItem(cipher: Cipher) {
try { try {
await deleteCipher(authedFetch, cipher.id); const deleted = await deleteCipher(authedFetch, cipher.id);
await Promise.all([refetchCiphers(), refetchFolders()]); await decryptAndPatch(deleted);
await refetchFolders();
onNotify('success', t('txt_item_deleted')); onNotify('success', t('txt_item_deleted'));
} catch (error) { } catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_delete_item_failed')); 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) { async archiveVaultItem(cipher: Cipher) {
try { try {
await archiveCipher(authedFetch, cipher.id); const archived = await archiveCipher(authedFetch, cipher.id);
await Promise.all([refetchCiphers(), refetchFolders()]); await decryptAndPatch(archived);
await refetchFolders();
onNotify('success', t('txt_item_archived')); onNotify('success', t('txt_item_archived'));
} catch (error) { } catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_archive_item_failed')); 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) { async unarchiveVaultItem(cipher: Cipher) {
try { try {
await unarchiveCipher(authedFetch, cipher.id); const unarchived = await unarchiveCipher(authedFetch, cipher.id);
await Promise.all([refetchCiphers(), refetchFolders()]); await decryptAndPatch(unarchived);
await refetchFolders();
onNotify('success', t('txt_item_unarchived')); onNotify('success', t('txt_item_unarchived'));
} catch (error) { } catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_unarchive_item_failed')); onNotify('error', error instanceof Error ? error.message : t('txt_unarchive_item_failed'));
+11 -7
View File
@@ -766,7 +766,7 @@ export async function createCipher(
authedFetch: AuthedFetch, authedFetch: AuthedFetch,
session: SessionState, session: SessionState,
draft: VaultDraft draft: VaultDraft
): Promise<{ id: string }> { ): Promise<Cipher> {
const payload = await buildCipherPayload(session, draft, null); const payload = await buildCipherPayload(session, draft, null);
const resp = await authedFetch('/api/ciphers', { const resp = await authedFetch('/api/ciphers', {
@@ -775,9 +775,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); const body = await parseJson<Cipher>(resp);
if (!body?.id) throw new Error('Create item failed'); if (!body?.id) throw new Error('Create item failed');
return { id: body.id }; return body;
} }
export async function updateCipher( export async function updateCipher(
@@ -785,7 +785,7 @@ export async function updateCipher(
session: SessionState, session: SessionState,
cipher: Cipher, cipher: Cipher,
draft: VaultDraft draft: VaultDraft
): Promise<void> { ): Promise<Cipher> {
const payload = await buildCipherPayload(session, draft, cipher); const payload = await buildCipherPayload(session, draft, cipher);
const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(cipher.id)}`, { const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(cipher.id)}`, {
@@ -794,25 +794,29 @@ export async function updateCipher(
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
if (!resp.ok) throw new Error('Update item failed'); if (!resp.ok) throw new Error('Update item failed');
return (await parseJson<Cipher>(resp))!;
} }
export async function deleteCipher(authedFetch: AuthedFetch, cipherId: string): Promise<void> { export async function deleteCipher(authedFetch: AuthedFetch, cipherId: string): Promise<Cipher> {
const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(cipherId)}`, { method: 'DELETE' }); const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(cipherId)}`, { method: 'DELETE' });
if (!resp.ok) throw new Error('Delete item failed'); if (!resp.ok) throw new Error('Delete item failed');
return (await parseJson<Cipher>(resp))!;
} }
export async function archiveCipher(authedFetch: AuthedFetch, cipherId: string): Promise<void> { export async function archiveCipher(authedFetch: AuthedFetch, cipherId: string): Promise<Cipher> {
const id = String(cipherId || '').trim(); const id = String(cipherId || '').trim();
if (!id) throw new Error('Cipher id is required'); if (!id) throw new Error('Cipher id is required');
const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(id)}/archive`, { method: 'PUT' }); const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(id)}/archive`, { method: 'PUT' });
if (!resp.ok) throw new Error('Archive item failed'); if (!resp.ok) throw new Error('Archive item failed');
return (await parseJson<Cipher>(resp))!;
} }
export async function unarchiveCipher(authedFetch: AuthedFetch, cipherId: string): Promise<void> { export async function unarchiveCipher(authedFetch: AuthedFetch, cipherId: string): Promise<Cipher> {
const id = String(cipherId || '').trim(); const id = String(cipherId || '').trim();
if (!id) throw new Error('Cipher id is required'); if (!id) throw new Error('Cipher id is required');
const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(id)}/unarchive`, { method: 'PUT' }); const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(id)}/unarchive`, { method: 'PUT' });
if (!resp.ok) throw new Error('Unarchive item failed'); if (!resp.ok) throw new Error('Unarchive item failed');
return (await parseJson<Cipher>(resp))!;
} }
export async function bulkDeleteCiphers(authedFetch: AuthedFetch, ids: string[]): Promise<void> { export async function bulkDeleteCiphers(authedFetch: AuthedFetch, ids: string[]): Promise<void> {
+123
View File
@@ -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<string> {
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<Cipher> {
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;
}