diff --git a/app/routes/auth.py b/app/routes/auth.py index b806c6a..1f35efa 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -922,8 +922,11 @@ def recovery_items(): the client must derive it by decrypting the recovery blob with the recovery code. This ensures only the holder of the recovery code can compute the proof. - The challenge row is NOT consumed here — it is consumed by the final - POST /recover call so that endpoint can also validate the proof. + Replay prevention: the challenge is consumed (deleted) on success, then + immediately re-issued with the same expected_proof but a new nonce and a + fresh TTL. This means each call to /recovery/items rotates the challenge, + so a captured X-Recovery-Proof header cannot be replayed by a third party. + POST /recover will consume the rotated challenge on final commit. Items are returned as encrypted ciphertext blobs only. """ from app.models.recovery_challenge import RecoveryChallenge @@ -938,11 +941,9 @@ def recovery_items(): if not user or not user.recovery_enc_salt: return jsonify({'error': 'No recovery data found'}), 404 - # Validate against the DB-stored challenge without consuming it — - # POST /recover will consume it atomically on commit. - challenge = RecoveryChallenge.query.filter_by(user_id=user.id).first() - from datetime import datetime, timezone - if not challenge or challenge.expires_at < datetime.now(timezone.utc).replace(tzinfo=None): + # Consume the current challenge atomically. + challenge = RecoveryChallenge.consume(user.id) + if not challenge: return jsonify({'error': 'No active recovery challenge. Call /recovery/data first.'}), 400 if not verify_recovery_proof(challenge.expected_proof, client_proof): @@ -957,6 +958,17 @@ def recovery_items(): db.session.commit() return jsonify({'error': 'Invalid recovery proof'}), 401 + # Re-issue a fresh challenge with the same expected_proof but a new nonce + # and TTL. POST /recover will consume this rotated challenge on final commit. + # The client continues to send the same proof value — no client change needed. + new_nonce = generate_recovery_nonce() + RecoveryChallenge.create( + user_id=user.id, + nonce=new_nonce, + expected_proof=challenge.expected_proof, # same proof, new nonce + ) + db.session.commit() + from app.models.vault_item import VaultItem items = VaultItem.query.filter_by(user_id=user.id).all() return jsonify({ diff --git a/app/static/js/vault.js b/app/static/js/vault.js index 1302f0b..fe4a004 100644 --- a/app/static/js/vault.js +++ b/app/static/js/vault.js @@ -130,9 +130,11 @@ const Vault = (() => { res = await fetch(path, merged); } - if (!res.ok && res.status !== 404) { + if (!res.ok) { const err = await res.json().catch(() => ({})); - throw new Error(err.error || `HTTP ${res.status}`); + const e = new Error(err.error || `HTTP ${res.status}`); + e.status = res.status; + throw e; } return res; } @@ -974,7 +976,35 @@ const Vault = (() => { let _importViewInitialised = false; + function _resetImportView() { + _importRows = []; + const fileInput = document.getElementById("import-file-input"); + const fileNameEl = document.getElementById("import-file-name"); + const previewEl = document.getElementById("import-preview"); + const confirmBtn = document.getElementById("btn-import-confirm"); + const resultEl = document.getElementById("import-result"); + if (fileInput) fileInput.value = ""; + if (fileNameEl) fileNameEl.textContent = "No file chosen"; + if (previewEl) { + previewEl.innerHTML = ""; + previewEl.classList.add("hidden"); + } + if (confirmBtn) { + confirmBtn.disabled = true; + confirmBtn.textContent = "Import items"; + delete confirmBtn.dataset.mode; + } + if (resultEl) { + resultEl.innerHTML = ""; + resultEl.classList.add("hidden"); + } + } + function loadImportExportView() { + // Reset import panel state every time the view is entered so a previous + // import result or file preview is never shown stale on re-entry. + _resetImportView(); + if (_importViewInitialised) return; _importViewInitialised = true; @@ -1238,7 +1268,6 @@ const Vault = (() => { }); if (!res) return; const data = await res.json(); - if (!res.ok) throw new Error(data.error || "Import failed."); resultEl.innerHTML = `✅ Imported ${data.imported} item(s)${data.skipped ? `, skipped ${data.skipped} malformed row(s)` : ""}.`; resultEl.className = "import-result import-result-ok"; @@ -3987,4 +4016,4 @@ const Vault = (() => { } })(); -document.addEventListener("DOMContentLoaded", Vault.init); +document.addEventListener("DOMContentLoaded", Vault.init); \ No newline at end of file