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 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, ) 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('/', 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.""" user = db.session.get(User, g.current_user_id) shares = ( SharedItem.query .filter( db.or_( SharedItem.recipient_email == user.email, SharedItem.recipient_id == user.id, ) ) .order_by(SharedItem.created_at.desc()) .all() ) result = [] for s in shares: d = s.to_dict() owner = db.session.get(User, 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//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