05/18 Enhanced codes and functionalities 3

This commit is contained in:
2026-05-18 11:56:52 -04:00
parent 4364daecef
commit aa8ea397d6
2 changed files with 52 additions and 11 deletions
+19 -7
View File
@@ -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({
+32 -3
View File
@@ -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 <strong>${data.imported}</strong> item(s)${data.skipped ? `, skipped ${data.skipped} malformed row(s)` : ""}.`;
resultEl.className = "import-result import-result-ok";