from datetime import datetime, timezone from flask_login import UserMixin from argon2 import PasswordHasher from argon2.exceptions import VerifyMismatchError, VerificationError, InvalidHashError from sqlalchemy.dialects.mysql import INTEGER from app import db class User(db.Model, UserMixin): __tablename__ = 'users' id = db.Column(INTEGER(unsigned=True), autoincrement=True, primary_key=True) email = db.Column(db.String(255), unique=True, nullable=False, index=True) # master_hash: Argon2id hash of the client-derived PBKDF2 auth_hash # The raw master password is NEVER sent to or stored on the server. master_hash = db.Column(db.String(255), nullable=False) # enc_key_salt: random 16-byte salt (base64) generated at registration. # Returned to the client on login so it can re-derive the AES-256-GCM vault key. # The server never uses this for decryption — it is opaque to us. enc_key_salt = db.Column(db.String(64), nullable=False) created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc).replace(tzinfo=None), nullable=False) last_login = db.Column(db.DateTime, nullable=True) # TOTP / MFA # totp_secret: AES-256-GCM ciphertext of the base32 TOTP secret, base64-encoded. # Encrypted server-side with the TOTP_ENCRYPTION_KEY from config. # totp_iv: base64-encoded 12-byte GCM nonce for the above. totp_secret = db.Column(db.String(255), nullable=True) totp_iv = db.Column(db.String(64), nullable=True) totp_enabled = db.Column(db.Boolean, default=False, nullable=False) # ECDH P-256 sharing keypair # Public key: raw uncompressed point (65 bytes), base64-encoded (~88 chars), stored plaintext sharing_public_key = db.Column(db.String(128), nullable=True) # Private key: JWK, AES-256-GCM encrypted with the user's vault key sharing_private_key_enc = db.Column(db.Text, nullable=True) sharing_private_key_iv = db.Column(db.String(64), nullable=True) # Account recovery — enc_key_salt re-encrypted with a client-derived recovery key. # NULL means the user has not set up a recovery code yet. # The server never sees the recovery code — only the ciphertext of enc_key_salt. recovery_enc_salt = db.Column(db.String(128), nullable=True) recovery_iv = db.Column(db.String(64), nullable=True) # Brute-force lockout — incremented on every failed login attempt, # reset to 0 on success. locked_until is set to now()+15min after # MAX_FAILED_LOGINS consecutive failures. failed_login_count = db.Column(db.Integer, default=0, nullable=False, server_default='0') locked_until = db.Column(db.DateTime, nullable=True) # MFA backup codes — JSON array of Argon2id-hashed one-time codes. # Each code is consumed (removed from the array) on use. # NULL means no backup codes have been generated yet. mfa_backup_codes = db.Column(db.Text, nullable=True) folders = db.relationship('Folder', backref='owner', lazy='dynamic', cascade='all, delete-orphan') vault_items = db.relationship('VaultItem', backref='owner', lazy='dynamic', cascade='all, delete-orphan') def check_password(self, auth_hash: str) -> bool: ph = PasswordHasher() try: return ph.verify(self.master_hash, auth_hash) except (VerifyMismatchError, VerificationError, InvalidHashError): return False def __repr__(self): return f''