From 2ea0b2c14c1e7784876caad0c9385aef3164247c Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Sat, 25 Apr 2026 15:52:00 +0800 Subject: [PATCH] feat: Adds an API to update attachment metadata, supporting the repair of encrypted information of old attachments --- .gitignore | 1 + src/handlers/attachments.ts | 58 +++++++++++++ src/router-authenticated.ts | 6 ++ webapp/src/App.tsx | 74 ++++++++++++++-- webapp/src/lib/api/vault.ts | 169 ++++++++++++++++++++++++++++++++---- 5 files changed, 287 insertions(+), 21 deletions(-) diff --git a/.gitignore b/.gitignore index 43e4c69..a619233 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,4 @@ tmp/ .tmp/ nodewarden.wiki/ +AGENTS.md \ No newline at end of file diff --git a/src/handlers/attachments.ts b/src/handlers/attachments.ts index 91a81d2..14a3810 100644 --- a/src/handlers/attachments.ts +++ b/src/handlers/attachments.ts @@ -279,6 +279,64 @@ export async function handleGetAttachment( }); } +// PUT /api/ciphers/{cipherId}/attachment/{attachmentId}/metadata +// 修正旧附件的加密元数据,供官方客户端按当前 Bitwarden 契约解密。 +export async function handleUpdateAttachmentMetadata( + request: Request, + env: Env, + userId: string, + cipherId: string, + attachmentId: string +): Promise { + const storage = new StorageService(env.DB); + + const cipher = await storage.getCipher(cipherId); + if (!cipher || cipher.userId !== userId) { + return errorResponse('Cipher not found', 404); + } + + const attachment = await storage.getAttachment(attachmentId); + if (!attachment || attachment.cipherId !== cipherId) { + return errorResponse('Attachment not found', 404); + } + + let body: { fileName?: string | null; key?: string | null }; + try { + body = await request.json(); + } catch { + return errorResponse('Invalid JSON', 400); + } + + if (!Object.prototype.hasOwnProperty.call(body, 'fileName') && !Object.prototype.hasOwnProperty.call(body, 'key')) { + return errorResponse('No metadata fields supplied', 400); + } + + if (Object.prototype.hasOwnProperty.call(body, 'fileName')) { + const fileName = String(body.fileName || '').trim(); + if (!fileName) return errorResponse('fileName is required', 400); + attachment.fileName = fileName; + } + if (Object.prototype.hasOwnProperty.call(body, 'key')) { + const key = body.key == null ? null : String(body.key || '').trim(); + attachment.key = key || null; + } + + await storage.saveAttachment(attachment); + const revisionInfo = await storage.updateCipherRevisionDate(cipherId); + if (revisionInfo) { + await notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate); + } + + return jsonResponse({ + object: 'attachment', + id: attachment.id, + fileName: attachment.fileName, + key: attachment.key, + size: String(Number(attachment.size) || 0), + sizeName: attachment.sizeName, + }); +} + // GET /api/attachments/{cipherId}/{attachmentId}?token=xxx // Public download endpoint (uses token for auth instead of header) export async function handlePublicDownloadAttachment( diff --git a/src/router-authenticated.ts b/src/router-authenticated.ts index bb1e8c3..7c983b3 100644 --- a/src/router-authenticated.ts +++ b/src/router-authenticated.ts @@ -60,6 +60,7 @@ import { handleCreateAttachment, handleUploadAttachment, handleGetAttachment, + handleUpdateAttachmentMetadata, handleDeleteAttachment, } from './handlers/attachments'; import { handleAuthenticatedDeviceRoute } from './router-devices'; @@ -201,6 +202,11 @@ export async function handleAuthenticatedRoute( if (method === 'DELETE') return handleDeleteAttachment(request, env, userId, cipherId, attachmentId); } + const attachmentMetadataMatch = subPath.match(/^\/attachment\/([a-f0-9-]+)\/metadata$/i); + if (attachmentMetadataMatch && (method === 'POST' || method === 'PUT')) { + return handleUpdateAttachmentMetadata(request, env, userId, cipherId, attachmentMetadataMatch[1]); + } + const attachmentDeleteMatch = subPath.match(/^\/attachment\/([a-f0-9-]+)\/delete$/i); if (attachmentDeleteMatch && method === 'POST') { return handleDeleteAttachment(request, env, userId, cipherId, attachmentDeleteMatch[1]); diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index 49470dc..6465ea5 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -25,10 +25,11 @@ import { buildSendShareKey, getSends } from '@/lib/api/send'; import { getCiphers, getFolders, + repairCipherAttachmentMetadata, updateFolder, } from '@/lib/api/vault'; import { silentlyRepairBackupSettingsIfNeeded } from '@/lib/backup-settings-repair'; -import { base64ToBytes, decryptBw, decryptStr } from '@/lib/crypto'; +import { base64ToBytes, decryptBw, decryptStr, encryptBw } from '@/lib/crypto'; import { buildPublicSendUrl, deriveSendKeyParts, @@ -803,6 +804,34 @@ export default function App() { return value; } }; + const sameBytes = (a: Uint8Array, b: Uint8Array) => { + 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; + }; + const decryptFieldWithSource = async ( + value: string | null | undefined, + itemEnc: Uint8Array, + itemMac: Uint8Array + ): Promise<{ text: string; source: 'item' | 'user' | 'plain' }> => { + const raw = String(value || '').trim(); + if (!raw) return { text: '', source: 'plain' }; + try { + return { text: await decryptStr(raw, itemEnc, itemMac), source: 'item' }; + } catch { + // 继续尝试旧 user key 数据。 + } + if (!sameBytes(itemEnc, encKey) || !sameBytes(itemMac, macKey)) { + try { + return { text: await decryptStr(raw, encKey, macKey), source: 'user' }; + } catch { + // 保留原文。 + } + } + return { text: raw, source: 'plain' }; + }; const folders = await Promise.all( foldersQuery.data.map(async (folder) => ({ @@ -908,10 +937,45 @@ export default function App() { } if (Array.isArray(cipher.attachments)) { nextCipher.attachments = await Promise.all( - cipher.attachments.map(async (attachment) => ({ - ...attachment, - decFileName: await decryptField(attachment.fileName || '', itemEnc, itemMac), - })) + cipher.attachments.map(async (attachment) => { + const attachmentId = String(attachment?.id || '').trim(); + const fileNameResult = await decryptFieldWithSource(attachment.fileName || '', itemEnc, itemMac); + const metadata: { fileName?: string; key?: string | null } = {}; + + if (attachmentId && fileNameResult.source === 'user') { + metadata.fileName = await encryptBw(new TextEncoder().encode(fileNameResult.text), itemEnc, itemMac); + } + + const attachmentKey = String(attachment?.key || '').trim(); + if ( + attachmentId && + attachmentKey && + looksLikeCipherString(attachmentKey) && + (!sameBytes(itemEnc, encKey) || !sameBytes(itemMac, macKey)) + ) { + try { + await decryptBw(attachmentKey, itemEnc, itemMac); + } catch { + try { + const rawAttachmentKey = await decryptBw(attachmentKey, encKey, macKey); + if (rawAttachmentKey.length >= 64) { + metadata.key = await encryptBw(rawAttachmentKey, itemEnc, itemMac); + } + } catch { + // 文件下载时会继续尝试旧格式。 + } + } + } + + if (attachmentId && Object.keys(metadata).length > 0) { + void repairCipherAttachmentMetadata(authedFetch, cipher.id, attachmentId, metadata); + } + + return { + ...attachment, + decFileName: fileNameResult.text, + }; + }) ); } return nextCipher; diff --git a/webapp/src/lib/api/vault.ts b/webapp/src/lib/api/vault.ts index 2054fa0..43e17b7 100644 --- a/webapp/src/lib/api/vault.ts +++ b/webapp/src/lib/api/vault.ts @@ -13,6 +13,7 @@ import { parseErrorMessage, parseJson, uploadDirectEncryptedPayload, + uploadWithProgress, type AuthedFetch, } from './shared'; import { readResponseBytesWithProgress } from '../download'; @@ -273,6 +274,98 @@ export async function deleteCipherAttachment( if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Delete attachment failed')); } +export async function repairCipherAttachmentMetadata( + authedFetch: AuthedFetch, + cipherId: string, + attachmentId: string, + metadata: { fileName?: string; key?: string | null } +): Promise { + const resp = await authedFetch( + `/api/ciphers/${encodeURIComponent(cipherId)}/attachment/${encodeURIComponent(attachmentId)}/metadata`, + { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(metadata), + } + ); + if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Update attachment metadata failed')); +} + +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 decryptCipherStringWithKey( + value: string, + enc: Uint8Array, + mac: Uint8Array +): Promise { + try { + return await decryptBw(value, enc, mac); + } catch { + return null; + } +} + +async function decryptAttachmentFileName( + rawFileName: string, + itemKeys: { enc: Uint8Array; mac: Uint8Array }, + userKeys: { enc: Uint8Array; mac: Uint8Array } +): Promise<{ fileName: string; source: 'plain' | 'item' | 'user' }> { + const fallback = rawFileName || 'attachment.bin'; + if (!rawFileName || !looksLikeCipherString(rawFileName)) return { fileName: fallback, source: 'plain' }; + + try { + const fileName = await decryptStr(rawFileName, itemKeys.enc, itemKeys.mac); + if (fileName) return { fileName, source: 'item' }; + } catch { + // 继续尝试旧 user key 文件名。 + } + + if (!sameBytes(itemKeys.enc, userKeys.enc) || !sameBytes(itemKeys.mac, userKeys.mac)) { + try { + const fileName = await decryptStr(rawFileName, userKeys.enc, userKeys.mac); + if (fileName) return { fileName, source: 'user' }; + } catch { + // 保留原始文件名。 + } + } + + return { fileName: fallback, source: 'plain' }; +} + +type AttachmentDecryptMode = 'attachment-item' | 'attachment-user' | 'legacy-item' | 'legacy-user'; + +interface AttachmentDecryptCandidate { + mode: AttachmentDecryptMode; + enc: Uint8Array; + mac: Uint8Array; + rawAttachmentKey: Uint8Array | null; +} + +async function uploadRepairedAttachmentBlob( + authedFetch: AuthedFetch, + session: SessionState, + cipherId: string, + attachmentId: string, + encryptedBytes: Uint8Array +): Promise { + if (!session.accessToken) throw new Error('Unauthorized'); + const payload = new ArrayBuffer(encryptedBytes.byteLength); + new Uint8Array(payload).set(encryptedBytes); + const resp = await uploadWithProgress(`/api/ciphers/${encodeURIComponent(cipherId)}/attachment/${encodeURIComponent(attachmentId)}`, { + accessToken: session.accessToken, + method: 'PUT', + headers: { 'Content-Type': 'application/octet-stream' }, + body: payload, + }); + if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Repair attachment upload failed')); +} + export async function downloadCipherAttachmentDecrypted( authedFetch: AuthedFetch, session: SessionState, @@ -293,32 +386,76 @@ export async function downloadCipherAttachmentDecrypted( const userEnc = base64ToBytes(session.symEncKey); const userMac = base64ToBytes(session.symMacKey); const itemKeys = await getCipherKeys(cipher, userEnc, userMac); + const userKeys = { enc: userEnc, mac: userMac }; - let fileEnc = itemKeys.enc; - let fileMac = itemKeys.mac; + const candidates: AttachmentDecryptCandidate[] = []; const keyCipher = String(info.key || '').trim(); if (keyCipher && looksLikeCipherString(keyCipher)) { - try { - const fileRawKey = await decryptBw(keyCipher, itemKeys.enc, itemKeys.mac); - if (fileRawKey.length >= 64) { - fileEnc = fileRawKey.slice(0, 32); - fileMac = fileRawKey.slice(32, 64); + const itemWrappedKey = await decryptCipherStringWithKey(keyCipher, itemKeys.enc, itemKeys.mac); + if (itemWrappedKey && itemWrappedKey.length >= 64) { + candidates.push({ + mode: 'attachment-item', + enc: itemWrappedKey.slice(0, 32), + mac: itemWrappedKey.slice(32, 64), + rawAttachmentKey: itemWrappedKey, + }); + } + + if (!sameBytes(itemKeys.enc, userEnc) || !sameBytes(itemKeys.mac, userMac)) { + const userWrappedKey = await decryptCipherStringWithKey(keyCipher, userEnc, userMac); + if (userWrappedKey && userWrappedKey.length >= 64) { + candidates.push({ + mode: 'attachment-user', + enc: userWrappedKey.slice(0, 32), + mac: userWrappedKey.slice(32, 64), + rawAttachmentKey: userWrappedKey, + }); } - } catch { - // fallback to item key } } + candidates.push({ mode: 'legacy-item', enc: itemKeys.enc, mac: itemKeys.mac, rawAttachmentKey: null }); + if (!sameBytes(itemKeys.enc, userEnc) || !sameBytes(itemKeys.mac, userMac)) { + candidates.push({ mode: 'legacy-user', enc: userEnc, mac: userMac, rawAttachmentKey: null }); + } - const plainBytes = await decryptBwFileData(encryptedBytes, fileEnc, fileMac); + let plainBytes: Uint8Array | null = null; + let usedCandidate: AttachmentDecryptCandidate | null = null; + for (const candidate of candidates) { + try { + plainBytes = await decryptBwFileData(encryptedBytes, candidate.enc, candidate.mac); + usedCandidate = candidate; + break; + } catch { + // 继续尝试下一种旧附件格式。 + } + } + if (!plainBytes || !usedCandidate) throw new Error('Attachment decryption failed'); const fileNameRaw = String(info.fileName || '').trim(); - let fileName = fileNameRaw || `attachment-${aid}`; - if (fileNameRaw && looksLikeCipherString(fileNameRaw)) { - try { - fileName = (await decryptStr(fileNameRaw, itemKeys.enc, itemKeys.mac)) || fileName; - } catch { - // keep fallback name + const nameResult = await decryptAttachmentFileName(fileNameRaw, itemKeys, userKeys); + const fileName = nameResult.fileName || `attachment-${aid}`; + + try { + const metadata: { fileName?: string; key?: string | null } = {}; + if (nameResult.source === 'user') { + metadata.fileName = await encryptTextValue(fileName, itemKeys.enc, itemKeys.mac) || undefined; } + + if (usedCandidate.mode === 'attachment-user' && usedCandidate.rawAttachmentKey) { + metadata.key = await encryptBw(usedCandidate.rawAttachmentKey, itemKeys.enc, itemKeys.mac); + } else if (usedCandidate.mode === 'legacy-item') { + metadata.key = null; + } else if (usedCandidate.mode === 'legacy-user') { + const repairedBytes = await encryptBwFileData(plainBytes, itemKeys.enc, itemKeys.mac); + await uploadRepairedAttachmentBlob(authedFetch, session, cid, aid, repairedBytes); + metadata.key = null; + } + + if (Object.keys(metadata).length > 0) { + await repairCipherAttachmentMetadata(authedFetch, cid, aid, metadata); + } + } catch { + // 修复失败不影响本次下载,旧附件内容已经成功解密。 } return { fileName, bytes: plainBytes };