Files
PassKeeper/app/routes/sharing.py
T

296 lines
10 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.shared_item import SharedItem
from app.models.audit_log import AuditLog
from app.services.auth_service import require_jwt
sharing_bp = Blueprint('sharing', __name__)
# ── Sharing keypair management ────────────────────────────────────────────────
@sharing_bp.route('/keys', methods=['GET'])
@limiter.limit('60 per minute')
@require_jwt
def get_my_keys():
"""Return current user's encrypted sharing private key (to decrypt client-side)."""
user = db.session.get(User, g.current_user_id)
if not user.sharing_public_key:
return jsonify({'keys_setup': False}), 200
return jsonify({
'keys_setup': True,
'public_key': user.sharing_public_key,
'private_key_enc': user.sharing_private_key_enc,
'private_key_iv': user.sharing_private_key_iv,
}), 200
@sharing_bp.route('/keys', methods=['POST'])
@limiter.limit('10 per minute')
@require_jwt
def store_my_keys():
"""Store ECDH keypair. Public key plaintext; private key encrypted with vault key."""
data = request.get_json(silent=True) or {}
public_key = data.get('public_key', '').strip()
private_key_enc = data.get('private_key_enc', '').strip()
private_key_iv = data.get('private_key_iv', '').strip()
if not public_key or not private_key_enc or not private_key_iv:
return jsonify({'error': 'public_key, private_key_enc, and private_key_iv are required'}), 400
user = db.session.get(User, g.current_user_id)
action = 'sharing_keys.update' if user.sharing_public_key else 'sharing_keys.create'
user.sharing_public_key = public_key
user.sharing_private_key_enc = private_key_enc
user.sharing_private_key_iv = private_key_iv
AuditLog.log(
user_id=g.current_user_id,
action=action,
resource_type='sharing_keys',
resource_id=g.current_user_id,
detail='ECDH sharing keypair stored/updated',
ip_address=client_ip(),
)
db.session.commit()
return jsonify({'message': 'Sharing keys stored'}), 200
@sharing_bp.route('/public-key', methods=['GET'])
@limiter.limit('30 per minute')
@require_jwt
def get_public_key():
"""Look up another user's ECDH public key by email (needed to create a share)."""
email = (request.args.get('email') or '').strip().lower()
if not email:
return jsonify({'error': 'email query param is required'}), 400
user = User.query.filter_by(email=email).first()
# Return the same 404 regardless of whether the email is registered,
# to prevent user enumeration by authenticated clients.
if not user or not user.sharing_public_key:
return jsonify({'error': 'User or sharing key not found'}), 404
return jsonify({
'user_id': user.id,
'email': user.email,
'public_key': user.sharing_public_key,
}), 200
# ── Outgoing shares ───────────────────────────────────────────────────────────
@sharing_bp.route('', methods=['GET'])
@limiter.limit('60 per minute')
@require_jwt
def list_outgoing():
"""List all items the current user has shared with others."""
shares = (
SharedItem.query
.filter_by(owner_id=g.current_user_id)
.order_by(SharedItem.created_at.desc())
.all()
)
result = []
for s in shares:
d = s.to_dict()
recipient = db.session.get(User, s.recipient_id) if s.recipient_id else None
d['recipient_name'] = recipient.email if recipient else s.recipient_email
result.append(d)
return jsonify(result), 200
@sharing_bp.route('', methods=['POST'])
@limiter.limit('30 per minute')
@require_jwt
def create_share():
"""
Share a vault item with another user.
The caller must already have:
1. Fetched the recipient's public key via GET /api/sharing/public-key?email=...
2. Loaded their own ECDH private key (decrypted client-side with vault key)
3. Derived the ECDH shared secret
4. Re-encrypted the item's plaintext with that shared secret → enc_data, iv
"""
data = request.get_json(silent=True) or {}
item_id = data.get('item_id')
recipient_email = (data.get('recipient_email') or '').strip().lower()
enc_data = data.get('enc_data', '')
iv = data.get('iv', '')
item_name = (data.get('item_name') or '').strip()
item_type = data.get('item_type', 'password')
# Encrypted display name — encrypted with the ECDH shared secret client-side.
enc_name = data.get('enc_name') or None
iv_name = data.get('iv_name') or None
# Optional expiry: number of days until the share expires (None = never).
# Accepted values: 1, 7, 30, 90, None.
expires_days = data.get('expires_days')
expires_at = None
if expires_days is not None:
try:
expires_days = int(expires_days)
if expires_days > 0:
from datetime import timedelta
expires_at = (
datetime.now(timezone.utc).replace(tzinfo=None)
+ timedelta(days=expires_days)
)
except (TypeError, ValueError):
pass
if not all([item_id, recipient_email, enc_data, iv, item_name]):
return jsonify({'error': 'item_id, recipient_email, enc_data, iv, item_name are required'}), 400
owner = db.session.get(User, g.current_user_id)
if owner.email == recipient_email:
return jsonify({'error': 'Cannot share an item with yourself'}), 400
# Verify the item belongs to the current user
from app.models.vault_item import VaultItem
item = VaultItem.query.filter_by(id=item_id, user_id=g.current_user_id).first()
if not item:
return jsonify({'error': 'Item not found'}), 404
recipient = User.query.filter_by(email=recipient_email).first()
share = SharedItem(
item_id=item_id,
owner_id=g.current_user_id,
recipient_email=recipient_email,
recipient_id=recipient.id if recipient else None,
item_name=item_name, # non-sensitive label (item_type value)
item_type=item_type,
enc_data=enc_data,
iv=iv,
enc_name=enc_name,
iv_name=iv_name,
expires_at=expires_at,
)
db.session.add(share)
db.session.flush() # populate share.id before logging
AuditLog.log(
user_id=g.current_user_id,
action='shared_item.create',
resource_type='shared_item',
resource_id=share.id,
detail=f'Shared {item_type} item (id={item_id}) with {recipient_email}',
ip_address=client_ip(),
)
db.session.commit()
return jsonify(share.to_dict()), 201
@sharing_bp.route('/<int:share_id>', methods=['DELETE'])
@limiter.limit('30 per minute')
@require_jwt
def delete_share(share_id):
share = SharedItem.query.filter_by(id=share_id, owner_id=g.current_user_id).first()
if not share:
return jsonify({'error': 'Share not found'}), 404
item_type_saved = share.item_type
recipient_email_saved = share.recipient_email
db.session.delete(share)
db.session.flush()
AuditLog.log(
user_id=g.current_user_id,
action='shared_item.delete',
resource_type='shared_item',
resource_id=share_id,
detail=f'Revoked share of {item_type_saved} item (share_id={share_id}) with {recipient_email_saved}',
ip_address=client_ip(),
)
db.session.commit()
return jsonify({'message': 'Share removed'}), 200
# ── Inbox (received shares) ───────────────────────────────────────────────────
@sharing_bp.route('/inbox', methods=['GET'])
@limiter.limit('60 per minute')
@require_jwt
def inbox():
"""List all items shared with the current user.
Expired unaccepted shares are excluded — they can no longer be acted on.
Expired accepted shares remain visible since the data was already accepted.
"""
user = db.session.get(User, g.current_user_id)
now = datetime.now(timezone.utc).replace(tzinfo=None)
shares = (
SharedItem.query
.filter(
db.or_(
SharedItem.recipient_email == user.email,
SharedItem.recipient_id == user.id,
),
# Exclude expired unaccepted shares.
db.or_(
SharedItem.accepted == True,
SharedItem.expires_at == None,
SharedItem.expires_at > now,
),
)
.order_by(SharedItem.created_at.desc())
.all()
)
owner_ids = {s.owner_id for s in shares}
owners = (
{u.id: u for u in User.query.filter(User.id.in_(owner_ids)).all()}
if owner_ids else {}
)
result = []
for s in shares:
d = s.to_dict()
owner = owners.get(s.owner_id)
d['owner_email'] = owner.email if owner else 'Unknown'
d['owner_public_key'] = owner.sharing_public_key if owner else None
result.append(d)
return jsonify(result), 200
@sharing_bp.route('/inbox/<int:share_id>/accept', methods=['POST'])
@limiter.limit('30 per minute')
@require_jwt
def accept_share(share_id):
"""Mark a received share as accepted (links recipient_id if not already set)."""
user = db.session.get(User, g.current_user_id)
share = SharedItem.query.filter(
SharedItem.id == share_id,
db.or_(
SharedItem.recipient_email == user.email,
SharedItem.recipient_id == user.id,
),
).first()
if not share:
return jsonify({'error': 'Share not found'}), 404
share.accepted = True
share.recipient_id = user.id
# Look up owner email for the audit log before committing
owner = db.session.get(User, share.owner_id)
owner_email = owner.email if owner else f'user_id={share.owner_id}'
AuditLog.log(
user_id=g.current_user_id,
action='shared_item.accept',
resource_type='shared_item',
resource_id=share.id,
detail=f'Accepted shared {share.item_type} item (share_id={share.id}) from {owner_email}',
ip_address=client_ip(),
)
db.session.commit()
d = share.to_dict()
d['owner_email'] = owner_email
d['owner_public_key'] = owner.sharing_public_key if owner else None
return jsonify(d), 200