79 lines
3.1 KiB
Python
79 lines
3.1 KiB
Python
from datetime import datetime, timezone
|
|
|
|
from sqlalchemy.dialects.mysql import INTEGER
|
|
|
|
from app import db
|
|
|
|
|
|
class SharedItem(db.Model):
|
|
"""
|
|
A vault item shared by one user with another.
|
|
|
|
Zero-knowledge: enc_data is re-encrypted by the SENDER using the ECDH shared
|
|
secret derived from sender's private key + recipient's P-256 public key.
|
|
The server stores only the ciphertext — it cannot decrypt it.
|
|
"""
|
|
__tablename__ = 'shared_items'
|
|
|
|
id = db.Column(INTEGER(unsigned=True), autoincrement=True, primary_key=True)
|
|
item_id = db.Column(
|
|
INTEGER(unsigned=True),
|
|
db.ForeignKey('vault_items.id', ondelete='CASCADE'),
|
|
nullable=False,
|
|
)
|
|
owner_id = db.Column(
|
|
INTEGER(unsigned=True),
|
|
db.ForeignKey('users.id', ondelete='CASCADE'),
|
|
nullable=False,
|
|
)
|
|
recipient_email = db.Column(db.String(255), nullable=False)
|
|
recipient_id = db.Column(
|
|
INTEGER(unsigned=True),
|
|
db.ForeignKey('users.id', ondelete='SET NULL'),
|
|
nullable=True,
|
|
)
|
|
# Non-sensitive fallback label (set to item_type, e.g. "password").
|
|
# The actual display name is stored encrypted in enc_name/iv_name below.
|
|
item_name = db.Column(db.String(255), nullable=False)
|
|
item_type = db.Column(db.String(20), nullable=False, default='password')
|
|
# ECDH-encrypted payload
|
|
enc_data = db.Column(db.Text, nullable=False)
|
|
iv = db.Column(db.String(64), nullable=False)
|
|
# Encrypted item name — AES-256-GCM with the ECDH shared secret.
|
|
# enc_name: base64 ciphertext of the display name string.
|
|
# iv_name : base64 12-byte GCM nonce.
|
|
# Nullable for backwards compatibility with rows created before this column existed.
|
|
enc_name = db.Column(db.String(512), nullable=True)
|
|
iv_name = db.Column(db.String(64), nullable=True)
|
|
|
|
accepted = db.Column(db.Boolean, default=False, nullable=False)
|
|
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc).replace(tzinfo=None), nullable=False)
|
|
# Optional expiry — NULL means the share never expires.
|
|
# Expired unaccepted shares are hidden from the inbox; accepted shares remain.
|
|
expires_at = db.Column(db.DateTime, nullable=True)
|
|
|
|
def is_expired(self) -> bool:
|
|
"""Return True if the share has an expiry and it has passed."""
|
|
if not self.expires_at:
|
|
return False
|
|
return datetime.now(timezone.utc).replace(tzinfo=None) >= self.expires_at
|
|
|
|
def to_dict(self):
|
|
return {
|
|
'id': self.id,
|
|
'item_id': self.item_id,
|
|
'owner_id': self.owner_id,
|
|
'recipient_email': self.recipient_email,
|
|
'recipient_id': self.recipient_id,
|
|
'item_name': self.item_name,
|
|
'item_type': self.item_type,
|
|
'enc_data': self.enc_data,
|
|
'iv': self.iv,
|
|
'enc_name': self.enc_name,
|
|
'iv_name': self.iv_name,
|
|
'accepted': self.accepted,
|
|
'created_at': self.created_at.isoformat() if self.created_at else None,
|
|
'expires_at': self.expires_at.isoformat() if self.expires_at else None,
|
|
'is_expired': self.is_expired(),
|
|
}
|