"""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// -> {state, pub, iv, ct} GET /accounts/keyshare/pending/ -> {requests: [...]} POST /accounts/keyshare//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})