mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
feat: Adds an API to update attachment metadata, supporting the repair of encrypted information of old attachments
This commit is contained in:
@@ -42,3 +42,4 @@ tmp/
|
|||||||
.tmp/
|
.tmp/
|
||||||
|
|
||||||
nodewarden.wiki/
|
nodewarden.wiki/
|
||||||
|
AGENTS.md
|
||||||
@@ -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<Response> {
|
||||||
|
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
|
// GET /api/attachments/{cipherId}/{attachmentId}?token=xxx
|
||||||
// Public download endpoint (uses token for auth instead of header)
|
// Public download endpoint (uses token for auth instead of header)
|
||||||
export async function handlePublicDownloadAttachment(
|
export async function handlePublicDownloadAttachment(
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ import {
|
|||||||
handleCreateAttachment,
|
handleCreateAttachment,
|
||||||
handleUploadAttachment,
|
handleUploadAttachment,
|
||||||
handleGetAttachment,
|
handleGetAttachment,
|
||||||
|
handleUpdateAttachmentMetadata,
|
||||||
handleDeleteAttachment,
|
handleDeleteAttachment,
|
||||||
} from './handlers/attachments';
|
} from './handlers/attachments';
|
||||||
import { handleAuthenticatedDeviceRoute } from './router-devices';
|
import { handleAuthenticatedDeviceRoute } from './router-devices';
|
||||||
@@ -201,6 +202,11 @@ export async function handleAuthenticatedRoute(
|
|||||||
if (method === 'DELETE') return handleDeleteAttachment(request, env, userId, cipherId, attachmentId);
|
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);
|
const attachmentDeleteMatch = subPath.match(/^\/attachment\/([a-f0-9-]+)\/delete$/i);
|
||||||
if (attachmentDeleteMatch && method === 'POST') {
|
if (attachmentDeleteMatch && method === 'POST') {
|
||||||
return handleDeleteAttachment(request, env, userId, cipherId, attachmentDeleteMatch[1]);
|
return handleDeleteAttachment(request, env, userId, cipherId, attachmentDeleteMatch[1]);
|
||||||
|
|||||||
+68
-4
@@ -25,10 +25,11 @@ import { buildSendShareKey, getSends } from '@/lib/api/send';
|
|||||||
import {
|
import {
|
||||||
getCiphers,
|
getCiphers,
|
||||||
getFolders,
|
getFolders,
|
||||||
|
repairCipherAttachmentMetadata,
|
||||||
updateFolder,
|
updateFolder,
|
||||||
} from '@/lib/api/vault';
|
} from '@/lib/api/vault';
|
||||||
import { silentlyRepairBackupSettingsIfNeeded } from '@/lib/backup-settings-repair';
|
import { silentlyRepairBackupSettingsIfNeeded } from '@/lib/backup-settings-repair';
|
||||||
import { base64ToBytes, decryptBw, decryptStr } from '@/lib/crypto';
|
import { base64ToBytes, decryptBw, decryptStr, encryptBw } from '@/lib/crypto';
|
||||||
import {
|
import {
|
||||||
buildPublicSendUrl,
|
buildPublicSendUrl,
|
||||||
deriveSendKeyParts,
|
deriveSendKeyParts,
|
||||||
@@ -803,6 +804,34 @@ export default function App() {
|
|||||||
return value;
|
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(
|
const folders = await Promise.all(
|
||||||
foldersQuery.data.map(async (folder) => ({
|
foldersQuery.data.map(async (folder) => ({
|
||||||
@@ -908,10 +937,45 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
if (Array.isArray(cipher.attachments)) {
|
if (Array.isArray(cipher.attachments)) {
|
||||||
nextCipher.attachments = await Promise.all(
|
nextCipher.attachments = await Promise.all(
|
||||||
cipher.attachments.map(async (attachment) => ({
|
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,
|
...attachment,
|
||||||
decFileName: await decryptField(attachment.fileName || '', itemEnc, itemMac),
|
decFileName: fileNameResult.text,
|
||||||
}))
|
};
|
||||||
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return nextCipher;
|
return nextCipher;
|
||||||
|
|||||||
+154
-17
@@ -13,6 +13,7 @@ import {
|
|||||||
parseErrorMessage,
|
parseErrorMessage,
|
||||||
parseJson,
|
parseJson,
|
||||||
uploadDirectEncryptedPayload,
|
uploadDirectEncryptedPayload,
|
||||||
|
uploadWithProgress,
|
||||||
type AuthedFetch,
|
type AuthedFetch,
|
||||||
} from './shared';
|
} from './shared';
|
||||||
import { readResponseBytesWithProgress } from '../download';
|
import { readResponseBytesWithProgress } from '../download';
|
||||||
@@ -273,6 +274,98 @@ export async function deleteCipherAttachment(
|
|||||||
if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Delete attachment failed'));
|
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<void> {
|
||||||
|
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<Uint8Array | null> {
|
||||||
|
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<void> {
|
||||||
|
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(
|
export async function downloadCipherAttachmentDecrypted(
|
||||||
authedFetch: AuthedFetch,
|
authedFetch: AuthedFetch,
|
||||||
session: SessionState,
|
session: SessionState,
|
||||||
@@ -293,32 +386,76 @@ export async function downloadCipherAttachmentDecrypted(
|
|||||||
const userEnc = base64ToBytes(session.symEncKey);
|
const userEnc = base64ToBytes(session.symEncKey);
|
||||||
const userMac = base64ToBytes(session.symMacKey);
|
const userMac = base64ToBytes(session.symMacKey);
|
||||||
const itemKeys = await getCipherKeys(cipher, userEnc, userMac);
|
const itemKeys = await getCipherKeys(cipher, userEnc, userMac);
|
||||||
|
const userKeys = { enc: userEnc, mac: userMac };
|
||||||
|
|
||||||
let fileEnc = itemKeys.enc;
|
const candidates: AttachmentDecryptCandidate[] = [];
|
||||||
let fileMac = itemKeys.mac;
|
|
||||||
const keyCipher = String(info.key || '').trim();
|
const keyCipher = String(info.key || '').trim();
|
||||||
if (keyCipher && looksLikeCipherString(keyCipher)) {
|
if (keyCipher && looksLikeCipherString(keyCipher)) {
|
||||||
try {
|
const itemWrappedKey = await decryptCipherStringWithKey(keyCipher, itemKeys.enc, itemKeys.mac);
|
||||||
const fileRawKey = await decryptBw(keyCipher, itemKeys.enc, itemKeys.mac);
|
if (itemWrappedKey && itemWrappedKey.length >= 64) {
|
||||||
if (fileRawKey.length >= 64) {
|
candidates.push({
|
||||||
fileEnc = fileRawKey.slice(0, 32);
|
mode: 'attachment-item',
|
||||||
fileMac = fileRawKey.slice(32, 64);
|
enc: itemWrappedKey.slice(0, 32),
|
||||||
}
|
mac: itemWrappedKey.slice(32, 64),
|
||||||
} catch {
|
rawAttachmentKey: itemWrappedKey,
|
||||||
// fallback to item key
|
});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const plainBytes = await decryptBwFileData(encryptedBytes, fileEnc, fileMac);
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
const fileNameRaw = String(info.fileName || '').trim();
|
||||||
let fileName = fileNameRaw || `attachment-${aid}`;
|
const nameResult = await decryptAttachmentFileName(fileNameRaw, itemKeys, userKeys);
|
||||||
if (fileNameRaw && looksLikeCipherString(fileNameRaw)) {
|
const fileName = nameResult.fileName || `attachment-${aid}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
fileName = (await decryptStr(fileNameRaw, itemKeys.enc, itemKeys.mac)) || fileName;
|
const metadata: { fileName?: string; key?: string | null } = {};
|
||||||
} catch {
|
if (nameResult.source === 'user') {
|
||||||
// keep fallback name
|
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 };
|
return { fileName, bytes: plainBytes };
|
||||||
|
|||||||
Reference in New Issue
Block a user