diora-web/accounts/keyshare.py
mrwnslz 077e6d4543
Some checks failed
Build and push Docker image / build (push) Successful in 17s
Test / test (push) Successful in 11m22s
Test / e2e (push) Failing after 1m29s
Anmelden mit Authelia als zweiter Weg, Bücher auf neuen Geräten per Code entsperren (sw CACHE v161)
Auf der Login-Seite gibt es „Mit Authelia anmelden“, wenn der Server dafür
eingerichtet ist. Das eigene Konto wird einmal in den Einstellungen verknüpft,
die Anmeldung mit Passwort bleibt. Fremde Authelia-Konten kommen nicht hinein,
neue Konten entstehen darüber nicht.

Fehlt einem Gerät der Bücher-Schlüssel (nach der Authelia-Anmeldung oder nach
gelöschtem Speicher), erzeugt diora keinen neuen mehr, der die Bücher
unlesbar machen würde. Stattdessen fragt es: Ein schon entsperrtes Gerät zeigt
denselben Code und gibt den Schlüssel verschlüsselt frei, oder man gibt einmal
das diora-Passwort ein. Der Server sieht den Schlüssel dabei nie.

Details: docs/accounts.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 15:01:25 +01:00

134 lines
5.1 KiB
Python

"""Passing the book key from an unlocked device to a new one (models.KeyShare).
The book key never reaches the server in the clear: the new device posts an ECDH P-256
public key, an unlocked device of the same account answers with its own public key and
the book key AES-GCM-encrypted under the shared secret. Both devices show a code made
from the request's public key (static/js/app/33-unlock.js), so the user approves the
right device. Requests run out after a few minutes; an answer is handed out once.
POST /accounts/keyshare/ {pub, device} -> {token}
GET /accounts/keyshare/<token>/ -> {state, pub, iv, ct}
GET /accounts/keyshare/pending/ -> {requests: [...]}
POST /accounts/keyshare/<token>/answer/ {pub, iv, ct} | {decline: true}
"""
import base64
import binascii
import json
from datetime import timedelta
from django.http import JsonResponse
from django.utils import timezone
from django.views.decorators.http import require_http_methods
from .models import KeyShare
LIFETIME = timedelta(minutes=10)
# Only requests whose device asked lately are offered: a closed tab drops out quickly.
ALIVE = timedelta(seconds=20)
MAX_OPEN = 5
def _auth(request):
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
return None
def _body(request):
try:
body = json.loads(request.body or b'{}')
except (json.JSONDecodeError, ValueError):
return None
return body if isinstance(body, dict) else None
def _b64(value, low, high):
"""A base64 string whose decoded length lies in [low, high], else None."""
if not isinstance(value, str) or len(value) > 4 * high:
return None
try:
raw = base64.b64decode(value, validate=True)
except (binascii.Error, ValueError):
return None
return value if low <= len(raw) <= high else None
def _purge(user):
KeyShare.objects.filter(user=user, created_at__lt=timezone.now() - LIFETIME).delete()
@require_http_methods(['POST'])
def create(request):
if (r := _auth(request)):
return r
body = _body(request)
pub = _b64((body or {}).get('pub'), 65, 65) # uncompressed P-256 point
if pub is None:
return JsonResponse({'error': 'pub required'}, status=400)
_purge(request.user)
# A device that reloads asks anew: the oldest requests make room instead of blocking it.
stale = KeyShare.objects.filter(user=request.user).order_by('-created_at').values_list('pk', flat=True)[MAX_OPEN - 1:]
KeyShare.objects.filter(pk__in=list(stale)).delete()
device = str(body.get('device') or '')[:100]
share = KeyShare.objects.create(user=request.user, request_pub=pub, device=device,
polled_at=timezone.now())
return JsonResponse({'token': share.token})
@require_http_methods(['GET'])
def status(request, token):
if (r := _auth(request)):
return r
_purge(request.user)
share = KeyShare.objects.filter(user=request.user, token=token).first()
if share is None:
return JsonResponse({'state': 'expired'})
if share.declined:
share.delete()
return JsonResponse({'state': 'declined'})
if not share.answer_ct:
share.polled_at = timezone.now()
share.save(update_fields=['polled_at'])
return JsonResponse({'state': 'pending'})
data = {'state': 'answered', 'pub': share.answer_pub, 'iv': share.answer_iv, 'ct': share.answer_ct}
share.delete() # handed out once
return JsonResponse(data)
@require_http_methods(['GET'])
def pending(request):
if (r := _auth(request)):
return r
_purge(request.user)
open_ = KeyShare.objects.filter(user=request.user, declined=False, answer_ct='',
polled_at__gte=timezone.now() - ALIVE).order_by('-created_at')
return JsonResponse({'requests': [
{'token': s.token, 'pub': s.request_pub, 'device': s.device,
'created_at': s.created_at.isoformat()} for s in open_]})
@require_http_methods(['POST'])
def answer(request, token):
if (r := _auth(request)):
return r
_purge(request.user)
share = KeyShare.objects.filter(user=request.user, token=token).first()
if share is None:
return JsonResponse({'error': 'expired'}, status=404)
if share.answer_ct or share.declined:
return JsonResponse({'error': 'already answered'}, status=409)
body = _body(request) or {}
if body.get('decline'):
share.declined = True
share.answered_at = timezone.now()
share.save(update_fields=['declined', 'answered_at'])
return JsonResponse({'ok': True})
pub = _b64(body.get('pub'), 65, 65)
iv = _b64(body.get('iv'), 12, 12)
ct = _b64(body.get('ct'), 32 + 16, 32 + 16) # 256-bit key + GCM tag
if not (pub and iv and ct):
return JsonResponse({'error': 'pub, iv and ct required'}, status=400)
share.answer_pub, share.answer_iv, share.answer_ct = pub, iv, ct
share.answered_at = timezone.now()
share.save(update_fields=['answer_pub', 'answer_iv', 'answer_ct', 'answered_at'])
return JsonResponse({'ok': True})