mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
feat: add URI checksum repair functionality for ciphers
This commit is contained in:
@@ -175,6 +175,16 @@ export function normalizeCipherLoginForCompatibility(login: any): any {
|
|||||||
return next;
|
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 {
|
function normalizeFido2CredentialsForCompatibility(credentials: any): any[] | null {
|
||||||
if (!Array.isArray(credentials) || credentials.length === 0) return null;
|
if (!Array.isArray(credentials) || credentials.length === 0) return null;
|
||||||
const requiredEncryptedKeys = [
|
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 (!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);
|
await storage.saveCipher(cipher);
|
||||||
const revisionDate = await storage.updateRevisionDate(userId);
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
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 (!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 syncIncomingAttachmentMetadata(storage, cipher.id, cipherData);
|
||||||
await storage.saveCipher(cipher);
|
await storage.saveCipher(cipher);
|
||||||
const revisionDate = await storage.updateRevisionDate(userId);
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
import { clearAuditLogs, getAuditLogSettings, listAdminInvites, listAdminUsers, listAuditLogs, saveAuditLogSettings, type AuditLogFilters } from '@/lib/api/admin';
|
import { clearAuditLogs, getAuditLogSettings, listAdminInvites, listAdminUsers, listAuditLogs, saveAuditLogSettings, type AuditLogFilters } from '@/lib/api/admin';
|
||||||
import { getDomainRules, saveDomainRules } from '@/lib/api/domains';
|
import { getDomainRules, saveDomainRules } from '@/lib/api/domains';
|
||||||
import { getSends } from '@/lib/api/send';
|
import { getSends } from '@/lib/api/send';
|
||||||
|
import { repairCipherUriChecksums } from '@/lib/api/vault';
|
||||||
import { getCachedVaultCoreSnapshot, loadVaultCoreSyncSnapshot } from '@/lib/api/vault-sync';
|
import { getCachedVaultCoreSnapshot, loadVaultCoreSyncSnapshot } from '@/lib/api/vault-sync';
|
||||||
import { silentlyRepairBackupSettingsIfNeeded } from '@/lib/backup-settings-repair';
|
import { silentlyRepairBackupSettingsIfNeeded } from '@/lib/backup-settings-repair';
|
||||||
import {
|
import {
|
||||||
@@ -229,6 +230,7 @@ export default function App() {
|
|||||||
const silentRefreshVaultRef = useRef<() => Promise<void>>(async () => {});
|
const silentRefreshVaultRef = useRef<() => Promise<void>>(async () => {});
|
||||||
const refreshAuthorizedDevicesRef = useRef<() => Promise<void>>(async () => {});
|
const refreshAuthorizedDevicesRef = useRef<() => Promise<void>>(async () => {});
|
||||||
const repairAttemptRef = useRef<string>('');
|
const repairAttemptRef = useRef<string>('');
|
||||||
|
const uriChecksumRepairAttemptRef = useRef<string>('');
|
||||||
const pendingVaultCoreQueryRefreshRef = useRef<Promise<{ data?: VaultCoreSnapshot } | unknown> | null>(null);
|
const pendingVaultCoreQueryRefreshRef = useRef<Promise<{ data?: VaultCoreSnapshot } | unknown> | null>(null);
|
||||||
const pendingVaultCoreRefreshRef = useRef<Promise<unknown> | null>(null);
|
const pendingVaultCoreRefreshRef = useRef<Promise<unknown> | null>(null);
|
||||||
const notificationRefreshTimerRef = useRef<number | null>(null);
|
const notificationRefreshTimerRef = useRef<number | null>(null);
|
||||||
@@ -1038,6 +1040,7 @@ export default function App() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (session?.accessToken) return;
|
if (session?.accessToken) return;
|
||||||
repairAttemptRef.current = '';
|
repairAttemptRef.current = '';
|
||||||
|
uriChecksumRepairAttemptRef.current = '';
|
||||||
}, [session?.accessToken]);
|
}, [session?.accessToken]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1078,6 +1081,17 @@ export default function App() {
|
|||||||
setDecryptedFolders(result.folders);
|
setDecryptedFolders(result.folders);
|
||||||
setDecryptedCiphers(result.ciphers);
|
setDecryptedCiphers(result.ciphers);
|
||||||
setVaultInitialDecryptDone(true);
|
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) {
|
} catch (error) {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
const message = error instanceof Error ? error.message : t('txt_decrypt_failed_2');
|
const message = error instanceof Error ? error.message : t('txt_decrypt_failed_2');
|
||||||
|
|||||||
@@ -666,6 +666,136 @@ async function getCipherKeys(
|
|||||||
return { enc: userEnc, mac: userMac, key: null };
|
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<Record<string, unknown>> = [];
|
||||||
|
|
||||||
|
for (const entry of cipher.login.uris) {
|
||||||
|
if (!entry || typeof entry !== 'object') continue;
|
||||||
|
const { decUri: _decUri, ...encryptedEntry } = entry as Record<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
|
||||||
|
return {
|
||||||
|
login: {
|
||||||
|
...encryptedLogin,
|
||||||
|
uris: uris as Cipher['login']['uris'],
|
||||||
|
} as Cipher['login'],
|
||||||
|
changed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function repairCipherUriChecksums(
|
||||||
|
authedFetch: AuthedFetch,
|
||||||
|
session: SessionState,
|
||||||
|
ciphers: Cipher[]
|
||||||
|
): Promise<number> {
|
||||||
|
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<string, unknown> = {
|
||||||
|
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(
|
async function buildCipherPayload(
|
||||||
session: SessionState,
|
session: SessionState,
|
||||||
draft: VaultDraft,
|
draft: VaultDraft,
|
||||||
|
|||||||
Reference in New Issue
Block a user