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/<pk>/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 <noreply@anthropic.com>
This commit is contained in:
parent
e3a9c61c05
commit
1426c69a73
3 changed files with 128 additions and 1 deletions
|
|
@ -6,6 +6,7 @@ urlpatterns = [
|
||||||
path('upload/', views.upload_book, name='upload_book'),
|
path('upload/', views.upload_book, name='upload_book'),
|
||||||
path('<int:pk>/data/', views.get_book_data, name='get_book_data'),
|
path('<int:pk>/data/', views.get_book_data, name='get_book_data'),
|
||||||
path('<int:pk>/delete/', views.delete_book, name='delete_book'),
|
path('<int:pk>/delete/', views.delete_book, name='delete_book'),
|
||||||
|
path('<int:pk>/rekey/', views.rekey_book, name='rekey_book'),
|
||||||
path('<int:pk>/progress/', views.save_progress, name='save_book_progress'),
|
path('<int:pk>/progress/', views.save_progress, name='save_book_progress'),
|
||||||
path('<int:pk>/highlights/', views.book_highlights, name='book_highlights'),
|
path('<int:pk>/highlights/', views.book_highlights, name='book_highlights'),
|
||||||
path('<int:pk>/bookmarks/', views.book_bookmarks, name='book_bookmarks'),
|
path('<int:pk>/bookmarks/', views.book_bookmarks, name='book_bookmarks'),
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,39 @@ def get_book_data(request, pk):
|
||||||
return JsonResponse({'data_ct': book.data_ct, 'data_iv': book.data_iv})
|
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
|
@csrf_exempt
|
||||||
@require_http_methods(['POST'])
|
@require_http_methods(['POST'])
|
||||||
def delete_book(request, pk):
|
def delete_book(request, pk):
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,7 @@
|
||||||
|
|
||||||
<details {% if password_form_open %}open{% endif %} style="margin-top:1rem;">
|
<details {% if password_form_open %}open{% endif %} style="margin-top:1rem;">
|
||||||
<summary class="btn" style="display:inline-block;cursor:pointer;">Change password</summary>
|
<summary class="btn" style="display:inline-block;cursor:pointer;">Change password</summary>
|
||||||
<form method="post" action="{% url 'change_password' %}" style="margin-top:1rem; display:flex; flex-direction:column; gap:0.6rem; max-width:320px;">
|
<form id="pw-change-form" method="post" action="{% url 'change_password' %}" style="margin-top:1rem; display:flex; flex-direction:column; gap:0.6rem; max-width:320px;">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{% for field in password_form %}
|
{% for field in password_form %}
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -89,6 +89,7 @@
|
||||||
{% if password_form.non_field_errors %}
|
{% if password_form.non_field_errors %}
|
||||||
<div class="message message-error" style="padding:4px 8px; font-size:0.8rem;">{{ password_form.non_field_errors|join:", " }}</div>
|
<div class="message message-error" style="padding:4px 8px; font-size:0.8rem;">{{ password_form.non_field_errors|join:", " }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<div id="pw-change-status" style="font-size:0.85rem; color:#888;"></div>
|
||||||
<div><button type="submit" class="btn">Save</button></div>
|
<div><button type="submit" class="btn">Save</button></div>
|
||||||
</form>
|
</form>
|
||||||
</details>
|
</details>
|
||||||
|
|
@ -142,6 +143,98 @@ async function _getOrCreateEncKey() {
|
||||||
return key;
|
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) {
|
async function uploadBackground(input) {
|
||||||
const file = input.files[0];
|
const file = input.files[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue