Files
PassKeeper/app/routes/emergency.py
T
nngo b84a6d9245
CI / Python lint (flake8) (push) Has been cancelled
CI / Python syntax check (push) Has been cancelled
CI / Alembic migration chain (push) Has been cancelled
CI / JavaScript syntax check (push) Has been cancelled
CI / Pytest (push) Has been cancelled
CI / Build extension zip (push) Has been cancelled
Aug 26 - Enhance security 4
2026-08-26 14:19:25 -04:00

359 lines
13 KiB
Python

from datetime import datetime, timezone
from flask import Blueprint, request, jsonify, g
from app import db, limiter, client_ip
from app.models.user import User
from app.models.emergency_access import EmergencyAccess
from app.models.audit_log import AuditLog
from app.services.auth_service import require_jwt
emergency_bp = Blueprint('emergency', __name__)
@emergency_bp.route('', methods=['GET'])
@limiter.limit('30 per minute')
@require_jwt
def list_emergency():
"""Return emergency access records both as grantor and as grantee."""
user = db.session.get(User, 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()
grantor_ids = {ea.grantor_id for ea in access}
grantors = (
{u.id: u for u in User.query.filter(User.id.in_(grantor_ids)).all()}
if grantor_ids else {}
)
return jsonify({
'grants': [ea.to_dict(grantor_email=user.email) for ea in grants],
'access': [_ea_as_grantee(ea, grantors.get(ea.grantor_id)) for ea in access],
}), 200
def _log_for_both(ea: EmergencyAccess, action: str, grantor_detail: str,
grantee_detail: str) -> None:
"""
Write the audit entry twice — once under each party's user_id.
/api/auth/audit-log filters by user_id, so an entry written only under the
acting user is invisible to the other party. That meant a grantee could
request access and retrieve the vault snapshot without a single line of it
appearing in the grantor's own audit log or security dashboard — the person
whose vault it was had no way to see it had happened.
Until email notifications exist (roadmap item 4), the grantor's audit log is
the only channel that reaches them, so it must carry these events.
"""
AuditLog.log(
user_id=ea.grantor_id,
action=action,
resource_type='emergency_access',
resource_id=ea.id,
detail=grantor_detail,
ip_address=client_ip(),
)
if ea.grantee_id and ea.grantee_id != ea.grantor_id:
AuditLog.log(
user_id=ea.grantee_id,
action=action,
resource_type='emergency_access',
resource_id=ea.id,
detail=grantee_detail,
ip_address=client_ip(),
)
def _ea_as_grantee(ea: EmergencyAccess, grantor: 'User | None' = None) -> dict:
if grantor is None:
grantor = db.session.get(User, 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'])
@limiter.limit('10 per minute')
@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()
try:
wait_days = int(data.get('wait_days', 7))
except (TypeError, ValueError):
return jsonify({'error': 'wait_days must be an integer'}), 400
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 = db.session.get(User, g.current_user_id)
if owner.email == grantee_email:
return jsonify({'error': 'Cannot designate yourself as emergency contact'}), 400
# No duplicate active grants — exclude only 'denied' records so a grantor
# cannot re-invite a contact who already has an active/pending/ready grant,
# but CAN re-invite after explicitly removing a previous grant (deleted rows).
# Note: 'denied' status is a terminal reset-to-ready internal state, not a
# standalone tombstone, so we block on all non-denied statuses.
existing = EmergencyAccess.query.filter(
EmergencyAccess.grantor_id == g.current_user_id,
EmergencyAccess.grantee_email == grantee_email,
EmergencyAccess.status.in_(['invited', 'accepted', 'ready', 'pending']),
).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.flush() # populate ea.id before logging
AuditLog.log(
user_id=g.current_user_id,
action='emergency_access.create',
resource_type='emergency_access',
resource_id=ea.id,
detail=f'Created emergency access invitation for {grantee_email} (wait: {wait_days}d)',
ip_address=client_ip(),
)
db.session.commit()
return jsonify(ea.to_dict(grantor_email=owner.email)), 201
@emergency_bp.route('/<int:ea_id>', methods=['DELETE'])
@limiter.limit('10 per minute')
@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
grantee_email = ea.grantee_email
db.session.delete(ea)
db.session.flush()
AuditLog.log(
user_id=g.current_user_id,
action='emergency_access.delete',
resource_type='emergency_access',
resource_id=ea_id,
detail=f'Removed emergency access grant for {grantee_email}',
ip_address=client_ip(),
)
db.session.commit()
return jsonify({'message': 'Emergency access removed'}), 200
@emergency_bp.route('/<int:ea_id>/accept', methods=['POST'])
@limiter.limit('10 per minute')
@require_jwt
def accept_emergency(ea_id):
"""Grantee accepts an emergency access invitation."""
user = db.session.get(User, 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.flush()
_log_for_both(
ea,
'emergency_access.accept',
grantor_detail=f'{ea.grantee_email} accepted your emergency access invitation',
grantee_detail=f'Accepted emergency access invitation from grantor_id={ea.grantor_id}',
)
db.session.commit()
return jsonify(_ea_as_grantee(ea)), 200
@emergency_bp.route('/<int:ea_id>/provide', methods=['POST'])
@limiter.limit('10 per minute')
@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'
AuditLog.log(
user_id=g.current_user_id,
action='emergency_access.provide_vault',
resource_type='emergency_access',
resource_id=ea.id,
detail=f'Provided encrypted vault snapshot for grantee {ea.grantee_email}',
ip_address=client_ip(),
)
db.session.commit()
grantor = db.session.get(User, g.current_user_id)
return jsonify(ea.to_dict(grantor_email=grantor.email)), 200
@emergency_bp.route('/<int:ea_id>/request', methods=['POST'])
@limiter.limit('10 per minute')
@require_jwt
def request_access(ea_id):
"""Grantee initiates an access request, starting the wait-period clock."""
user = db.session.get(User, 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.now(timezone.utc).replace(tzinfo=None)
db.session.flush()
# The grantor has `wait_days` to notice and deny this. If it only appeared in
# the grantee's audit log they would never see it in time.
_log_for_both(
ea,
'emergency_access.request',
grantor_detail=(
f'ACTION REQUIRED: {ea.grantee_email} requested emergency access to '
f'your vault. It unlocks in {ea.wait_days} day(s) unless you deny it.'
),
grantee_detail=(
f'Requested emergency vault access from grantor_id={ea.grantor_id} '
f'(wait: {ea.wait_days}d)'
),
)
db.session.commit()
return jsonify(_ea_as_grantee(ea)), 200
@emergency_bp.route('/<int:ea_id>/deny', methods=['POST'])
@limiter.limit('10 per minute')
@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
AuditLog.log(
user_id=g.current_user_id,
action='emergency_access.deny',
resource_type='emergency_access',
resource_id=ea.id,
detail=f'Denied emergency access request from grantee {ea.grantee_email}',
ip_address=client_ip(),
)
db.session.commit()
grantor = db.session.get(User, g.current_user_id)
return jsonify(ea.to_dict(grantor_email=grantor.email)), 200
@emergency_bp.route('/<int:ea_id>/vault', methods=['GET'])
@limiter.limit('10 per minute')
@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 = db.session.get(User, 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.now(timezone.utc).replace(tzinfo=None) - 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
# Record the retrieval. Access is deliberately not revoked afterwards — the
# grantor may be unable to re-provision, and a failed import must not strand
# the grantee — but every retrieval is counted and shown to the grantor, who
# can revoke the grant outright.
now = datetime.now(timezone.utc).replace(tzinfo=None)
is_first = ea.vault_retrieved_at is None
if is_first:
ea.vault_retrieved_at = now
ea.vault_retrieval_count = (ea.vault_retrieval_count or 0) + 1
_log_for_both(
ea,
'emergency_access.vault_retrieved',
grantor_detail=(
f'{ea.grantee_email} retrieved your emergency vault snapshot '
f'({"first" if is_first else f"retrieval #{ea.vault_retrieval_count}"}). '
'Remove the grant if this was not expected.'
),
grantee_detail=f'Retrieved emergency vault from grantor_id={ea.grantor_id}',
)
db.session.commit()
grantor = db.session.get(User, ea.grantor_id)
return jsonify({
'enc_vault': ea.enc_vault,
'grantor_public_key': grantor.sharing_public_key if grantor else None,
}), 200