"""App passwords: one generated password per mail app (SPEC App. D §D.5). A mail app (Thunderbird, a phone's Mail app) signs in to the hosted mailbox over IMAP/SMTP, where no 2FA code can be asked for. It gets a password of its own — 16 random letters, shown once — that can be revoked without touching the main password or the other apps. The webmail itself never accepts one. Stored as argon2id hashes (identity/passwords.py), the form the mail server's SQL directory reads (§D.3). The hash is of the bare 16 letters: the mail server compares exactly what an app sends, so the password is copied and entered without the spaces it is shown with. """ from __future__ import annotations import secrets import string import uuid from sqlalchemy import delete, func, select from sqlalchemy.ext.asyncio import AsyncSession from app.identity.passwords import hash_password, verify_password from app.models import UserAppPassword LENGTH = 16 # 26^16 ≈ 2^75 MAX_PER_USER = 25 NAME_MAX = 60 _ALPHABET = string.ascii_lowercase def generation_key(user_id: uuid.UUID) -> str: """Redis counter bumped whenever the user's app passwords change: DAV sign-ins cached under the old value stop counting (app/dav/auth.py).""" return f"apppw:gen:{user_id}" async def _changed(user_id: uuid.UUID) -> None: from app.redis_client import get_redis await get_redis().incr(generation_key(user_id)) class TooMany(Exception): """The user already has MAX_PER_USER app passwords.""" def generate() -> str: return "".join(secrets.choice(_ALPHABET) for _ in range(LENGTH)) async def list_for(db: AsyncSession, user_id: uuid.UUID) -> list[UserAppPassword]: rows = await db.scalars( select(UserAppPassword) .where(UserAppPassword.user_id == user_id) .order_by(UserAppPassword.created_at.desc()) ) return list(rows) async def create(db: AsyncSession, user_id: uuid.UUID, name: str) -> tuple[UserAppPassword, str]: """A new app password named ``name``; returns the row and the password itself, which is never available again.""" count = await db.scalar( select(func.count()).select_from(UserAppPassword).where(UserAppPassword.user_id == user_id) ) if (count or 0) >= MAX_PER_USER: raise TooMany password = generate() row = UserAppPassword( user_id=user_id, name=name.strip()[:NAME_MAX], password_hash=hash_password(password) ) db.add(row) await db.commit() return row, password async def revoke(db: AsyncSession, user_id: uuid.UUID, app_password_id: uuid.UUID) -> bool: result = await db.execute( delete(UserAppPassword).where( UserAppPassword.id == app_password_id, UserAppPassword.user_id == user_id ) ) await db.commit() await _changed(user_id) return bool(result.rowcount) # type: ignore[attr-defined] async def revoke_all(db: AsyncSession, user_id: uuid.UUID) -> None: """Every app signs in again (the main password was reset).""" await db.execute(delete(UserAppPassword).where(UserAppPassword.user_id == user_id)) await db.commit() await _changed(user_id) async def verify(db: AsyncSession, user_id: uuid.UUID, password: str) -> UserAppPassword | None: """The app password ``password`` matches, if any — for the mail server's sign-in and for tests; never for the webmail's own sign-in.""" if len(password) != LENGTH: return None for row in await list_for(db, user_id): if verify_password(row.password_hash, password): return row return None