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:
parent
f1903e1e61
commit
5c7cc4c6aa
8 changed files with 224 additions and 18 deletions
|
|
@ -216,7 +216,23 @@
|
|||
if (r.status === 402) return { exists: false, paid: false };
|
||||
if (!r.ok) throw new Error('sync status: HTTP ' + r.status);
|
||||
const body = await r.json();
|
||||
return { exists: !!body.exists, updated_at: body.updated_at, paid: true };
|
||||
return {
|
||||
exists: !!body.exists,
|
||||
orphaned: !!body.orphaned,
|
||||
updated_at: body.updated_at,
|
||||
paid: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Thrown by pullSync when the server reports the stored blob is
|
||||
// wrapped with a different server key (pepper rotation). Distinct
|
||||
// from BadPinError so the UI can swap the restore form for a
|
||||
// re-upload CTA instead of asking again for the PIN.
|
||||
class StaleBlobError extends Error {
|
||||
constructor(msg) {
|
||||
super(msg || 'Stored portfolio cannot be decrypted with the current server key.');
|
||||
this.name = 'StaleBlobError';
|
||||
}
|
||||
}
|
||||
|
||||
async function pushSync(pie, pin) {
|
||||
|
|
@ -245,6 +261,10 @@
|
|||
headers: { 'Accept': 'application/json' },
|
||||
});
|
||||
if (r.status === 404) return null;
|
||||
if (r.status === 410) {
|
||||
const body = await r.json().catch(() => ({}));
|
||||
throw new StaleBlobError(body.detail);
|
||||
}
|
||||
if (!r.ok) {
|
||||
const body = await r.json().catch(() => ({}));
|
||||
// 429 → server already throttling; bubble the message up unchanged.
|
||||
|
|
@ -273,6 +293,7 @@
|
|||
disableSync,
|
||||
clearCachedKey,
|
||||
BadPinError,
|
||||
StaleBlobError,
|
||||
// Exposed for tests / debugging:
|
||||
_packBlob: packBlob,
|
||||
_unpackBlob: unpackBlob,
|
||||
|
|
|
|||
|
|
@ -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’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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue