from datetime import datetime from flask import Blueprint, request, jsonify, g from app import db from app.models.user import User from app.models.emergency_access import EmergencyAccess from app.services.auth_service import require_jwt emergency_bp = Blueprint('emergency', __name__) @emergency_bp.route('', methods=['GET']) @require_jwt def list_emergency(): """Return emergency access records both as grantor and as grantee.""" user = User.query.get(g.current_user_id) grants = EmergencyAccess.query.filter_by(grantor_id=user.id).order_by( EmergencyAccess.created_at.desc() ).all() access = EmergencyAccess.query.filter( db.or_( EmergencyAccess.grantee_email == user.email, EmergencyAccess.grantee_id == user.id, ) ).order_by(EmergencyAccess.created_at.desc()).all() return jsonify({ 'grants': [ea.to_dict(grantor_email=user.email) for ea in grants], 'access': [_ea_as_grantee(ea) for ea in access], }), 200 def _ea_as_grantee(ea: EmergencyAccess) -> dict: grantor = User.query.get(ea.grantor_id) d = ea.to_dict(grantor_email=grantor.email if grantor else None) d['grantor_public_key'] = grantor.sharing_public_key if grantor else None return d @emergency_bp.route('', methods=['POST']) @require_jwt def create_emergency(): """Grantor creates an emergency access invitation for a trusted contact.""" data = request.get_json(silent=True) or {} grantee_email = (data.get('grantee_email') or '').strip().lower() wait_days = int(data.get('wait_days', 7)) if not grantee_email: return jsonify({'error': 'grantee_email is required'}), 400 if not (1 <= wait_days <= 90): return jsonify({'error': 'wait_days must be between 1 and 90'}), 400 owner = User.query.get(g.current_user_id) if owner.email == grantee_email: return jsonify({'error': 'Cannot designate yourself as emergency contact'}), 400 # No duplicate active grants existing = EmergencyAccess.query.filter( EmergencyAccess.grantor_id == g.current_user_id, EmergencyAccess.grantee_email == grantee_email, EmergencyAccess.status != 'denied', ).first() if existing: return jsonify({'error': 'Emergency access already set up for this contact'}), 409 grantee = User.query.filter_by(email=grantee_email).first() ea = EmergencyAccess( grantor_id=g.current_user_id, grantee_email=grantee_email, grantee_id=grantee.id if grantee else None, wait_days=wait_days, ) db.session.add(ea) db.session.commit() return jsonify(ea.to_dict(grantor_email=owner.email)), 201 @emergency_bp.route('/', methods=['DELETE']) @require_jwt def delete_emergency(ea_id): """Grantor removes an emergency access grant.""" ea = EmergencyAccess.query.filter_by(id=ea_id, grantor_id=g.current_user_id).first() if not ea: return jsonify({'error': 'Not found'}), 404 db.session.delete(ea) db.session.commit() return jsonify({'message': 'Emergency access removed'}), 200 @emergency_bp.route('//accept', methods=['POST']) @require_jwt def accept_emergency(ea_id): """Grantee accepts an emergency access invitation.""" user = User.query.get(g.current_user_id) ea = EmergencyAccess.query.filter( EmergencyAccess.id == ea_id, EmergencyAccess.status == 'invited', db.or_( EmergencyAccess.grantee_email == user.email, EmergencyAccess.grantee_id == user.id, ), ).first() if not ea: return jsonify({'error': 'Not found or not in invited state'}), 404 ea.status = 'accepted' ea.grantee_id = user.id db.session.commit() return jsonify(_ea_as_grantee(ea)), 200 @emergency_bp.route('//provide', methods=['POST']) @require_jwt def provide_vault(ea_id): """ Grantor provides the ECDH-encrypted vault snapshot for emergency recovery. The client re-encrypts each vault item's plaintext with the ECDH shared secret (grantor private key + grantee public key) and sends the JSON array as enc_vault. """ ea = EmergencyAccess.query.filter_by(id=ea_id, grantor_id=g.current_user_id).first() if not ea: return jsonify({'error': 'Not found'}), 404 if ea.status not in ('accepted', 'ready'): return jsonify({'error': 'Emergency access must be in accepted or ready state'}), 400 data = request.get_json(silent=True) or {} enc_vault = data.get('enc_vault', '') if not enc_vault: return jsonify({'error': 'enc_vault (JSON array) is required'}), 400 ea.enc_vault = enc_vault ea.status = 'ready' db.session.commit() grantor = User.query.get(g.current_user_id) return jsonify(ea.to_dict(grantor_email=grantor.email)), 200 @emergency_bp.route('//request', methods=['POST']) @require_jwt def request_access(ea_id): """Grantee initiates an access request, starting the wait-period clock.""" user = User.query.get(g.current_user_id) ea = EmergencyAccess.query.filter( EmergencyAccess.id == ea_id, EmergencyAccess.status == 'ready', db.or_( EmergencyAccess.grantee_email == user.email, EmergencyAccess.grantee_id == user.id, ), ).first() if not ea: return jsonify({'error': 'Not found or not in ready state'}), 404 ea.status = 'pending' ea.request_initiated_at = datetime.utcnow() db.session.commit() return jsonify(_ea_as_grantee(ea)), 200 @emergency_bp.route('//deny', methods=['POST']) @require_jwt def deny_access(ea_id): """Grantor denies a pending access request (resets to ready).""" ea = EmergencyAccess.query.filter_by(id=ea_id, grantor_id=g.current_user_id).first() if not ea: return jsonify({'error': 'Not found'}), 404 if ea.status != 'pending': return jsonify({'error': 'No pending request to deny'}), 400 ea.status = 'ready' ea.request_initiated_at = None db.session.commit() grantor = User.query.get(g.current_user_id) return jsonify(ea.to_dict(grantor_email=grantor.email)), 200 @emergency_bp.route('//vault', methods=['GET']) @require_jwt def get_emergency_vault(ea_id): """ Grantee retrieves the encrypted vault snapshot after the wait period has elapsed. Also returns the grantor's public key so the client can derive the ECDH secret. """ user = User.query.get(g.current_user_id) ea = EmergencyAccess.query.filter( EmergencyAccess.id == ea_id, db.or_( EmergencyAccess.grantee_email == user.email, EmergencyAccess.grantee_id == user.id, ), ).first() if not ea: return jsonify({'error': 'Not found'}), 404 if not ea.wait_elapsed: if ea.request_initiated_at: elapsed_secs = (datetime.utcnow() - ea.request_initiated_at).total_seconds() days_left = max(0, ea.wait_days - elapsed_secs / 86400) else: days_left = ea.wait_days return jsonify({ 'error': f'Wait period not yet elapsed ({days_left:.1f} day(s) remaining)' }), 403 grantor = User.query.get(ea.grantor_id) return jsonify({ 'enc_vault': ea.enc_vault, 'grantor_public_key': grantor.sharing_public_key if grantor else None, }), 200