mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
feat: implement cipher decryption functionality and update related API methods
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user