From 1426c69a73eab1fa30c4190ed166c590eefa2040 Mon Sep 17 00:00:00 2001 From: marwin Date: Mon, 25 May 2026 16:37:41 +0200 Subject: [PATCH] Accounts: re-encrypt all books on password change The encryption key is PBKDF2-derived from the login password. Changing the password without migrating the key would make all books undecryptable on the next login. - Add POST /books//rekey/ endpoint to replace a book's ciphertexts - Password change form is now JS-driven: before submitting to the server, derives the old key, verifies it matches localStorage, derives the new key, re-encrypts all books (data + meta) and their highlights/bookmarks, updates localStorage, then submits the Django form Co-Authored-By: Claude Sonnet 4.6 --- books/urls.py | 1 + books/views.py | 33 +++++++++++ templates/accounts/settings.html | 95 +++++++++++++++++++++++++++++++- 3 files changed, 128 insertions(+), 1 deletion(-) diff --git a/books/urls.py b/books/urls.py index 559f5fd..3781b38 100644 --- a/books/urls.py +++ b/books/urls.py @@ -6,6 +6,7 @@ urlpatterns = [ path('upload/', views.upload_book, name='upload_book'), path('/data/', views.get_book_data, name='get_book_data'), path('/delete/', views.delete_book, name='delete_book'), + path('/rekey/', views.rekey_book, name='rekey_book'), path('/progress/', views.save_progress, name='save_book_progress'), path('/highlights/', views.book_highlights, name='book_highlights'), path('/bookmarks/', views.book_bookmarks, name='book_bookmarks'), diff --git a/books/views.py b/books/views.py index a52b9b3..423a528 100644 --- a/books/views.py +++ b/books/views.py @@ -94,6 +94,39 @@ def get_book_data(request, pk): return JsonResponse({'data_ct': book.data_ct, 'data_iv': book.data_iv}) +@csrf_exempt +@require_http_methods(['POST']) +def rekey_book(request, pk): + err = _require_auth(request) + if err: + return err + + try: + book = EBook.objects.get(pk=pk, user=request.user) + except EBook.DoesNotExist: + return JsonResponse({'error': 'not found'}, status=404) + + try: + body = json.loads(request.body) + except (json.JSONDecodeError, ValueError): + return JsonResponse({'error': 'invalid JSON'}, status=400) + + meta_ct = body.get('meta_ct', '') + meta_iv = body.get('meta_iv', '') + data_ct = body.get('data_ct', '') + data_iv = body.get('data_iv', '') + + if not all([meta_ct, meta_iv, data_ct, data_iv]): + return JsonResponse({'error': 'meta_ct, meta_iv, data_ct, data_iv required'}, status=400) + + book.meta_ct = meta_ct + book.meta_iv = meta_iv + book.data_ct = data_ct + book.data_iv = data_iv + book.save(update_fields=['meta_ct', 'meta_iv', 'data_ct', 'data_iv']) + return JsonResponse({'ok': True}) + + @csrf_exempt @require_http_methods(['POST']) def delete_book(request, pk): diff --git a/templates/accounts/settings.html b/templates/accounts/settings.html index ec72c1f..953a84d 100644 --- a/templates/accounts/settings.html +++ b/templates/accounts/settings.html @@ -75,7 +75,7 @@
Change password -
+ {% csrf_token %} {% for field in password_form %}
@@ -89,6 +89,7 @@ {% if password_form.non_field_errors %}
{{ password_form.non_field_errors|join:", " }}
{% endif %} +
@@ -142,6 +143,98 @@ async function _getOrCreateEncKey() { return key; } +function _hexToBytes(hex) { + const buf = new Uint8Array(hex.length / 2); + for (let i = 0; i < buf.length; i++) buf[i] = parseInt(hex.slice(i*2, i*2+2), 16); + return buf; +} + +async function _pbkdf2Key(password, username) { + const enc = new TextEncoder(); + const mat = await crypto.subtle.importKey('raw', enc.encode(password), 'PBKDF2', false, ['deriveKey']); + return crypto.subtle.deriveKey( + {name: 'PBKDF2', salt: enc.encode('diora:' + username), iterations: 200000, hash: 'SHA-256'}, + mat, {name: 'AES-GCM', length: 256}, true, ['encrypt', 'decrypt'] + ); +} + +async function _reEncrypt(oldKey, newKey, iv, ct) { + const plain = await crypto.subtle.decrypt({name: 'AES-GCM', iv: _hexToBytes(iv)}, oldKey, _base64ToBytes(ct)); + const newIv = crypto.getRandomValues(new Uint8Array(12)); + const newCt = await crypto.subtle.encrypt({name: 'AES-GCM', iv: newIv}, newKey, plain); + return {iv: _bytesToHex(newIv), ct: _bytesToBase64(newCt)}; +} + +document.getElementById('pw-change-form')?.addEventListener('submit', async function(e) { + e.preventDefault(); + const form = this; + const statusEl = document.getElementById('pw-change-status'); + const oldPw = form.old_password.value; + const newPw = form.new_password1.value; + const userId = {{ request.user.id }}; + const username = '{{ request.user.username }}'; + const storageKey = `diora_enc_key_${userId}`; + + statusEl.textContent = 'Schlüssel ableiten…'; + try { + const oldKey = await _pbkdf2Key(oldPw, username); + + // Verify old key matches localStorage + const stored = localStorage.getItem(storageKey); + if (stored) { + const storedBytes = _base64ToBytes(stored); + const derivedBytes = new Uint8Array(await crypto.subtle.exportKey('raw', oldKey)); + if (!storedBytes.every((b, i) => b === derivedBytes[i])) { + statusEl.textContent = 'Fehler: Das alte Passwort stimmt nicht mit dem gespeicherten Schlüssel überein. Bitte altes Passwort prüfen.'; + return; + } + } + + const newKey = await _pbkdf2Key(newPw, username); + + // Re-encrypt all books + statusEl.textContent = 'Bücherliste laden…'; + const books = await fetch('/books/').then(r => r.json()); + + for (let i = 0; i < books.length; i++) { + const book = books[i]; + statusEl.textContent = `Buch ${i + 1} / ${books.length} neu verschlüsseln…`; + + const newMeta = await _reEncrypt(oldKey, newKey, book.meta_iv, book.meta_ct); + const data = await fetch(`/books/${book.id}/data/`).then(r => r.json()); + const newData = await _reEncrypt(oldKey, newKey, data.data_iv, data.data_ct); + + await fetch(`/books/${book.id}/rekey/`, { + method: 'POST', + headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, + body: JSON.stringify({meta_ct: newMeta.ct, meta_iv: newMeta.iv, data_ct: newData.ct, data_iv: newData.iv}), + }); + + // Re-encrypt highlights and bookmarks if they exist + for (const endpoint of ['highlights', 'bookmarks']) { + const resp = await fetch(`/books/${book.id}/${endpoint}/`).then(r => r.json()); + if (resp.ct) { + const reenc = await _reEncrypt(oldKey, newKey, resp.iv, resp.ct); + await fetch(`/books/${book.id}/${endpoint}/`, { + method: 'POST', + headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, + body: JSON.stringify({ct: reenc.ct, iv: reenc.iv}), + }); + } + } + } + + // Update localStorage key + const newRaw = new Uint8Array(await crypto.subtle.exportKey('raw', newKey)); + localStorage.setItem(storageKey, _bytesToBase64(newRaw)); + + statusEl.textContent = 'Passwort ändern…'; + form.submit(); + } catch (err) { + statusEl.textContent = 'Fehler: ' + err.message; + } +}); + async function uploadBackground(input) { const file = input.files[0]; if (!file) return;