sync: detect orphaned blobs (pepper rotation) + fix AESGCM arg order

Adds an 8-byte HKDF fingerprint of the current pepper to portfolio_sync
rows. On fetch, a mismatch surfaces as 410 Gone (distinct from genuine
GCM corruption → 500), and the UI silently cleans up the dead row and
shows a soft "please re-import" notice instead of a confusing PIN
re-prompt. Legacy rows (pepper_fp NULL) are probed optimistically and
backfilled on success.

Also fixes a latent bug in unwrap(): AESGCM.decrypt args were swapped
(ct, nonce instead of nonce, ct), so restore-from-cloud always failed
even when the pepper was correct.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Giorgio Gilestro 2026-05-25 12:49:11 +02:00
parent f1903e1e61
commit 5c7cc4c6aa
8 changed files with 224 additions and 18 deletions

View file

@ -164,14 +164,52 @@
}[c]));
}
// Tiny one-shot flag the orphan-cleanup path sets so renderEmpty can
// surface a plain-English "your previous backup needs to be re-uploaded"
// line. Read-once; cleared as soon as it's shown.
function consumeBackupExpiredNotice() {
try {
if (sessionStorage.getItem('cassandra.sync.backupExpired') === '1') {
sessionStorage.removeItem('cassandra.sync.backupExpired');
return true;
}
} catch (e) { /* ignore */ }
return false;
}
function renderEmpty(mount) {
const expired = consumeBackupExpiredNotice();
const notice = expired
? '<div class="settings-row__hint" style="margin-bottom:8px;">' +
'Your previous cloud backup couldn&rsquo;t be restored on this server. ' +
'Please re-upload your portfolio to refresh it.' +
'</div>'
: '';
mount.innerHTML =
'<div class="empty" style="padding:16px;">' +
notice +
'No portfolio loaded in this browser. ' +
'<a href="/settings#import">Import a T212 CSV →</a>' +
'</div>';
}
// Silently remove an unrecoverable cloud blob and re-render. The user
// sees the standard empty state with a soft one-liner — no jargon, no
// extra buttons. The decision to remove is safe: the blob is already
// permanently undecryptable, so we're cleaning up dead state, not
// discarding user data.
async function autoCleanStaleBlob(mount) {
try {
await window.CassandraSync.disableSync();
} catch (e) {
console.warn('cassandra.sync: auto-clean of stale blob failed', e);
}
try {
sessionStorage.setItem('cassandra.sync.backupExpired', '1');
} catch (e) { /* ignore */ }
renderEmpty(mount);
}
function renderRestoreFromCloud(mount, status) {
const lastSynced = status.updated_at
? new Date(status.updated_at).toISOString().replace('T', ' ').slice(0, 16) + ' UTC'
@ -212,6 +250,12 @@
savePie(pie);
mountAndRender();
} catch (e2) {
if (e2 && e2.name === 'StaleBlobError') {
// Pepper rotated since the blob was written — silently clean
// up and fall through to the empty state with a soft notice.
autoCleanStaleBlob(mount);
return;
}
err.textContent = (e2 && e2.name === 'BadPinError')
? 'Incorrect PIN.'
: (e2.message || 'Could not restore.');
@ -417,7 +461,14 @@
catch (e) { console.warn('sync status check failed', e); }
}
if (status && status.paid && status.exists) {
renderRestoreFromCloud(mount, status);
if (status.orphaned) {
// Pepper rotated since the blob was written — clean up
// silently and show the standard empty state with a soft
// "please re-upload" notice.
autoCleanStaleBlob(mount);
} else {
renderRestoreFromCloud(mount, status);
}
} else {
renderEmpty(mount);
}