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 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. 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 Replay prevention: the challenge is consumed (deleted) on success, then
POST /recover call so that endpoint can also validate the proof. 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. Items are returned as encrypted ciphertext blobs only.
""" """
from app.models.recovery_challenge import RecoveryChallenge from app.models.recovery_challenge import RecoveryChallenge
@@ -938,11 +941,9 @@ def recovery_items():
if not user or not user.recovery_enc_salt: if not user or not user.recovery_enc_salt:
return jsonify({'error': 'No recovery data found'}), 404 return jsonify({'error': 'No recovery data found'}), 404
# Validate against the DB-stored challenge without consuming it — # Consume the current challenge atomically.
# POST /recover will consume it atomically on commit. challenge = RecoveryChallenge.consume(user.id)
challenge = RecoveryChallenge.query.filter_by(user_id=user.id).first() if not challenge:
from datetime import datetime, timezone
if not challenge or challenge.expires_at < datetime.now(timezone.utc).replace(tzinfo=None):
return jsonify({'error': 'No active recovery challenge. Call /recovery/data first.'}), 400 return jsonify({'error': 'No active recovery challenge. Call /recovery/data first.'}), 400
if not verify_recovery_proof(challenge.expected_proof, client_proof): if not verify_recovery_proof(challenge.expected_proof, client_proof):
@@ -957,6 +958,17 @@ def recovery_items():
db.session.commit() db.session.commit()
return jsonify({'error': 'Invalid recovery proof'}), 401 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 from app.models.vault_item import VaultItem
items = VaultItem.query.filter_by(user_id=user.id).all() items = VaultItem.query.filter_by(user_id=user.id).all()
return jsonify({ return jsonify({
+32 -3
View File
@@ -130,9 +130,11 @@ const Vault = (() => {
res = await fetch(path, merged); res = await fetch(path, merged);
} }
if (!res.ok && res.status !== 404) { if (!res.ok) {
const err = await res.json().catch(() => ({})); 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; return res;
} }
@@ -974,7 +976,35 @@ const Vault = (() => {
let _importViewInitialised = false; 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() { 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; if (_importViewInitialised) return;
_importViewInitialised = true; _importViewInitialised = true;
@@ -1238,7 +1268,6 @@ const Vault = (() => {
}); });
if (!res) return; if (!res) return;
const data = await res.json(); 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.innerHTML = `✅ Imported <strong>${data.imported}</strong> item(s)${data.skipped ? `, skipped ${data.skipped} malformed row(s)` : ""}.`;
resultEl.className = "import-result import-result-ok"; resultEl.className = "import-result import-result-ok";