Files
nngo 0d7d9c1403
CI / Python lint (flake8) (push) Has been cancelled
CI / Python syntax check (push) Has been cancelled
CI / Alembic migration chain (push) Has been cancelled
CI / JavaScript syntax check (push) Has been cancelled
CI / Pytest (push) Has been cancelled
CI / Build extension zip (push) Has been cancelled
Aug 26 - Update password detect against off field 2
2026-08-26 15:05:48 -04:00

82 lines
4.8 KiB
Python

from datetime import datetime, timezone
from flask_login import UserMixin
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)
# recovery_verifier: 64 hex chars (256 bits), derived client-side from the
# recovery code ALONE:
# PBKDF2(recovery_code, "passkeeper-recovery-verifier:" + email, 200k, SHA-256)
# Used only as the HMAC key for the recovery challenge-response proof.
# It is deliberately independent of enc_key_salt: enc_key_salt doubles as the
# vault-key PBKDF2 salt and is handed to the client at login, so keying the
# proof with it let anyone holding the password forge a proof and pull the
# whole encrypted vault from /recovery/items without a second factor.
# NULL = legacy recovery code; the proof falls back to enc_key_salt.
recovery_verifier = 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)
# Session generation counter. Every issued JWT carries the value current at
# the time it was minted; require_jwt rejects tokens whose claim no longer
# matches. Incrementing this revokes every outstanding access and refresh
# token at once, which is what a master-password change must do — otherwise
# a stolen refresh token outlives the password it was obtained under.
# Tokens issued before this column existed decode with epoch 0 and stay
# valid until the next credential change.
token_epoch = db.Column(db.Integer, default=0, nullable=False, server_default='0')
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')
# NOTE: there is intentionally no check_password() here.
#
# It existed, was called from nowhere, and used default Argon2 parameters
# with no rehash-on-login handling — so any caller that found it would have
# silently bypassed the transparent parameter upgrade in
# auth_service.verify_auth_token(). Verification goes through
# verify_auth_token(auth_hash, user.master_hash, user=user) so the stored
# hash is upgraded when ARGON2_* settings change.
def __repr__(self):
return f'<User {self.email}>'