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>
134 lines
5.1 KiB
Python
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})
|