From c50247b8fe34e2fe51354735a4681ebfdc7bfc1c Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Mon, 18 May 2026 01:59:02 +0800 Subject: [PATCH] feat: add URI checksum repair functionality for ciphers --- src/handlers/ciphers.ts | 18 +++++ webapp/src/App.tsx | 14 ++++ webapp/src/lib/api/vault.ts | 130 ++++++++++++++++++++++++++++++++++++ 3 files changed, 162 insertions(+) diff --git a/src/handlers/ciphers.ts b/src/handlers/ciphers.ts index 4f30773..2df6422 100644 --- a/src/handlers/ciphers.ts +++ b/src/handlers/ciphers.ts @@ -175,6 +175,16 @@ export function normalizeCipherLoginForCompatibility(login: any): any { return next; } +function hasMissingLoginUriChecksum(cipher: Cipher): boolean { + if (!cipher.key || !cipher.login || typeof cipher.login !== 'object') return false; + const uris = (cipher.login as any).uris; + if (!Array.isArray(uris)) return false; + return uris.some((uri: any) => { + if (!uri || typeof uri !== 'object') return false; + return isValidEncString(uri.uri) && !isValidEncString(uri.uriChecksum); + }); +} + function normalizeFido2CredentialsForCompatibility(credentials: any): any[] | null { if (!Array.isArray(credentials) || credentials.length === 0) return null; const requiredEncryptedKeys = [ @@ -677,6 +687,10 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str if (!folderOk) return errorResponse('Folder not found', 404); } + if (hasMissingLoginUriChecksum(cipher)) { + return errorResponse('Login URI checksum is required for item-key encrypted ciphers. Refresh NodeWarden and save the item again.', 400); + } + await storage.saveCipher(cipher); const revisionDate = await storage.updateRevisionDate(userId); notifyVaultSyncForRequest(request, env, userId, revisionDate); @@ -772,6 +786,10 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str if (!folderOk) return errorResponse('Folder not found', 404); } + if (hasMissingLoginUriChecksum(cipher)) { + return errorResponse('Login URI checksum is required for item-key encrypted ciphers. Refresh NodeWarden and save the item again.', 400); + } + await syncIncomingAttachmentMetadata(storage, cipher.id, cipherData); await storage.saveCipher(cipher); const revisionDate = await storage.updateRevisionDate(userId); diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index 0776324..a528c0b 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -25,6 +25,7 @@ import { import { clearAuditLogs, getAuditLogSettings, listAdminInvites, listAdminUsers, listAuditLogs, saveAuditLogSettings, type AuditLogFilters } from '@/lib/api/admin'; import { getDomainRules, saveDomainRules } from '@/lib/api/domains'; import { getSends } from '@/lib/api/send'; +import { repairCipherUriChecksums } from '@/lib/api/vault'; import { getCachedVaultCoreSnapshot, loadVaultCoreSyncSnapshot } from '@/lib/api/vault-sync'; import { silentlyRepairBackupSettingsIfNeeded } from '@/lib/backup-settings-repair'; import { @@ -229,6 +230,7 @@ export default function App() { const silentRefreshVaultRef = useRef<() => Promise>(async () => {}); const refreshAuthorizedDevicesRef = useRef<() => Promise>(async () => {}); const repairAttemptRef = useRef(''); + const uriChecksumRepairAttemptRef = useRef(''); const pendingVaultCoreQueryRefreshRef = useRef | null>(null); const pendingVaultCoreRefreshRef = useRef | null>(null); const notificationRefreshTimerRef = useRef(null); @@ -1038,6 +1040,7 @@ export default function App() { useEffect(() => { if (session?.accessToken) return; repairAttemptRef.current = ''; + uriChecksumRepairAttemptRef.current = ''; }, [session?.accessToken]); useEffect(() => { @@ -1078,6 +1081,17 @@ export default function App() { setDecryptedFolders(result.folders); setDecryptedCiphers(result.ciphers); setVaultInitialDecryptDone(true); + const repairKey = `${session.accessToken}:${encryptedCiphers.map((cipher) => `${cipher.id}:${cipher.revisionDate || ''}`).join(',')}`; + if (uriChecksumRepairAttemptRef.current !== repairKey) { + uriChecksumRepairAttemptRef.current = repairKey; + void repairCipherUriChecksums(authedFetch, session, result.ciphers) + .then((count) => { + if (count > 0) void refetchVaultCoreData(); + }) + .catch(() => { + // Best-effort compatibility repair must not interrupt normal vault loading. + }); + } } catch (error) { if (!active) return; const message = error instanceof Error ? error.message : t('txt_decrypt_failed_2'); diff --git a/webapp/src/lib/api/vault.ts b/webapp/src/lib/api/vault.ts index fbc2a45..471cf9e 100644 --- a/webapp/src/lib/api/vault.ts +++ b/webapp/src/lib/api/vault.ts @@ -666,6 +666,136 @@ async function getCipherKeys( return { enc: userEnc, mac: userMac, key: null }; } +async function repairCipherLoginUris( + cipher: Cipher, + enc: Uint8Array, + mac: Uint8Array +): Promise<{ login: Cipher['login']; changed: boolean }> { + if (!cipher.login || !Array.isArray(cipher.login.uris)) { + return { login: cipher.login ?? null, changed: false }; + } + + let changed = false; + const uris: Array> = []; + + for (const entry of cipher.login.uris) { + if (!entry || typeof entry !== 'object') continue; + const { decUri: _decUri, ...encryptedEntry } = entry as Record; + const rawUri = typeof entry.uri === 'string' ? entry.uri.trim() : ''; + if (!looksLikeCipherString(rawUri)) { + uris.push({ ...encryptedEntry }); + continue; + } + + let clearUri = String(entry.decUri || '').trim(); + if (!clearUri || looksLikeCipherString(clearUri)) { + try { + clearUri = (await decryptStr(rawUri, enc, mac)).trim(); + } catch { + uris.push({ ...encryptedEntry }); + continue; + } + } + + if (!clearUri) { + uris.push({ ...encryptedEntry }); + continue; + } + + const expectedChecksum = await sha256Base64(clearUri); + let currentChecksumOk = false; + const rawChecksum = typeof entry.uriChecksum === 'string' ? entry.uriChecksum.trim() : ''; + if (looksLikeCipherString(rawChecksum)) { + try { + currentChecksumOk = (await decryptStr(rawChecksum, enc, mac)) === expectedChecksum; + } catch { + currentChecksumOk = false; + } + } + + if (currentChecksumOk) { + uris.push({ ...encryptedEntry }); + continue; + } + + uris.push({ + ...encryptedEntry, + uri: rawUri, + uriChecksum: await encryptTextValue(expectedChecksum, enc, mac), + match: typeof entry.match === 'number' && Number.isFinite(entry.match) ? entry.match : null, + }); + changed = true; + } + + const { + decUsername: _decUsername, + decPassword: _decPassword, + decTotp: _decTotp, + ...encryptedLogin + } = cipher.login as Record; + + return { + login: { + ...encryptedLogin, + uris: uris as Cipher['login']['uris'], + } as Cipher['login'], + changed, + }; +} + +export async function repairCipherUriChecksums( + authedFetch: AuthedFetch, + session: SessionState, + ciphers: Cipher[] +): Promise { + if (!session.symEncKey || !session.symMacKey || !Array.isArray(ciphers) || ciphers.length === 0) { + return 0; + } + + const userEnc = base64ToBytes(session.symEncKey); + const userMac = base64ToBytes(session.symMacKey); + let repaired = 0; + + for (const cipher of ciphers) { + if (!cipher?.id || cipher.type !== 1 || !looksLikeCipherString(cipher.key) || !cipher.login || !Array.isArray(cipher.login.uris)) continue; + let itemKey: Uint8Array; + try { + itemKey = await decryptBw(String(cipher.key).trim(), userEnc, userMac); + } catch { + continue; + } + if (itemKey.length < 64) continue; + const keys = { enc: itemKey.slice(0, 32), mac: itemKey.slice(32, 64), key: String(cipher.key).trim() }; + const repair = await repairCipherLoginUris(cipher, keys.enc, keys.mac); + if (!repair.changed) continue; + + const payload: Record = { + type: cipher.type, + folderId: cipher.folderId ?? null, + favorite: !!cipher.favorite, + reprompt: cipher.reprompt ?? 0, + name: cipher.name ?? null, + notes: cipher.notes ?? null, + login: repair.login, + fields: Array.isArray(cipher.fields) + ? cipher.fields.map(({ decName: _decName, decValue: _decValue, ...field }) => field) + : null, + key: keys.key, + lastKnownRevisionDate: cipher.revisionDate ?? null, + }; + + const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(cipher.id)}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Repair URI checksum failed')); + repaired += 1; + } + + return repaired; +} + async function buildCipherPayload( session: SessionState, draft: VaultDraft,