90 lines
3.7 KiB
Python
90 lines
3.7 KiB
Python
from datetime import datetime, timezone, timedelta
|
|
|
|
from sqlalchemy.dialects.mysql import INTEGER
|
|
|
|
from app import db
|
|
|
|
|
|
class EmergencyAccess(db.Model):
|
|
"""
|
|
Emergency access grant from a vault owner (grantor) to a trusted contact (grantee).
|
|
|
|
Status flow:
|
|
invited → grantee calls /accept → accepted
|
|
accepted → grantor calls /provide → ready (enc_vault stored)
|
|
ready → grantee calls /request → pending (wait timer starts)
|
|
pending → grantor calls /deny → ready (reset, grantee can request again)
|
|
pending (wait_days elapsed) → grantable (grantee fetches vault)
|
|
|
|
Zero-knowledge: enc_vault is a JSON array of vault items re-encrypted by the grantor
|
|
using the ECDH shared secret (grantor private key + grantee public key).
|
|
"""
|
|
__tablename__ = 'emergency_access'
|
|
|
|
id = db.Column(INTEGER(unsigned=True), autoincrement=True, primary_key=True)
|
|
grantor_id = db.Column(
|
|
INTEGER(unsigned=True),
|
|
db.ForeignKey('users.id', ondelete='CASCADE'),
|
|
nullable=False,
|
|
)
|
|
grantee_email = db.Column(db.String(255), nullable=False)
|
|
grantee_id = db.Column(
|
|
INTEGER(unsigned=True),
|
|
db.ForeignKey('users.id', ondelete='SET NULL'),
|
|
nullable=True,
|
|
)
|
|
wait_days = db.Column(db.Integer, default=7, nullable=False)
|
|
# invited | accepted | ready | pending | denied
|
|
status = db.Column(db.String(20), default='invited', nullable=False)
|
|
request_initiated_at = db.Column(db.DateTime, nullable=True)
|
|
# JSON string: [{ id, name, item_type, enc_data, iv }, ...]
|
|
enc_vault = db.Column(db.Text, nullable=True)
|
|
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc).replace(tzinfo=None), nullable=False)
|
|
|
|
@property
|
|
def wait_elapsed(self):
|
|
"""True if the wait period has passed since the access request."""
|
|
if self.status != 'pending' or not self.request_initiated_at:
|
|
return False
|
|
return datetime.now(timezone.utc).replace(tzinfo=None) >= self.request_initiated_at + timedelta(days=self.wait_days)
|
|
|
|
def to_dict(self, grantor_email=None):
|
|
return {
|
|
'id': self.id,
|
|
'grantor_id': self.grantor_id,
|
|
'grantor_email': grantor_email,
|
|
'grantee_email': self.grantee_email,
|
|
'grantee_id': self.grantee_id,
|
|
'wait_days': self.wait_days,
|
|
'status': self.status,
|
|
'wait_elapsed': self.wait_elapsed,
|
|
'request_initiated_at': (
|
|
self.request_initiated_at.isoformat() if self.request_initiated_at else None
|
|
),
|
|
'created_at': self.created_at.isoformat() if self.created_at else None,
|
|
# True when enc_vault contains items in the old format (has a plaintext
|
|
# 'name' field instead of enc_name/iv_name). Grantor should re-provision.
|
|
'enc_vault_is_legacy': self._enc_vault_is_legacy(),
|
|
}
|
|
|
|
def _enc_vault_is_legacy(self) -> bool:
|
|
"""
|
|
Return True if the stored enc_vault snapshot was created before the
|
|
enc_name migration — i.e. any item has a 'name' key (plaintext) but
|
|
lacks 'enc_name'. Returns False if no snapshot exists or all items
|
|
use the new format.
|
|
"""
|
|
if not self.enc_vault:
|
|
return False
|
|
try:
|
|
import json
|
|
items = json.loads(self.enc_vault)
|
|
if not isinstance(items, list):
|
|
return False
|
|
# Old format: item has 'name' and no 'enc_name'
|
|
return any(
|
|
isinstance(item, dict) and 'name' in item and 'enc_name' not in item
|
|
for item in items
|
|
)
|
|
except (ValueError, TypeError):
|
|
return False |