terp-cap-mfa 0.27.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,73 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .eggs/
6
+ build/
7
+ dist/
8
+ .venv/
9
+ .venv-*/
10
+ venv/
11
+ .pytest_cache/
12
+ .mypy_cache/
13
+ .ruff_cache/
14
+ .coverage
15
+ htmlcov/
16
+
17
+ # uv
18
+ uv.lock
19
+
20
+ # Node
21
+ node_modules/
22
+ .pnpm-store/
23
+ *.tsbuildinfo
24
+
25
+ # Playwright (conformance e2e) artifacts
26
+ test-results/
27
+ playwright-report/
28
+ blob-report/
29
+ playwright/.cache/
30
+ .last-run.json
31
+
32
+ # Local frontend template render checks
33
+ apps/example/_frontend_tpl_check/
34
+
35
+ # Editor / OS
36
+ .DS_Store
37
+ .idea/
38
+ *.local
39
+
40
+ # Local environment overrides — never commit (a real .env may hold SECRET_KEY).
41
+ # The tracked template is `.env.example`.
42
+ .env
43
+ .env.*
44
+ !.env.example
45
+ !.env.example.jinja
46
+ # Rendered app-declared variables (environment.schema.json) — may hold secrets.
47
+ .app.env
48
+ # Per-service renders (a declaration scoped with "services"). A SEPARATE pattern
49
+ # because `.app.env` above is an exact name, not a glob: it does not match
50
+ # `.app.worker.env`, so without this line the one file that exists to hold a single
51
+ # worker's credentials would be the one file in the seam that gets committed.
52
+ # `.app.env.example` stays tracked -- it does not end in `.env`, so neither line
53
+ # claims it.
54
+ .app.*.env
55
+ # `terp smoke`'s throwaway database. Left in place deliberately after a failure — it is
56
+ # the state the chain died on — so it must not show up as an untracked file.
57
+ .terp-smoke.db
58
+
59
+ # graphify: a knowledge graph an agent builds FROM this repository. Derived
60
+ # data that is rebuilt on demand and goes stale the moment the code moves.
61
+ graphify-out/
62
+
63
+ # Playwright browsers recorded to a repo-local path. Needed rather than optional on
64
+ # Windows: the default location under %LOCALAPPDATA% is refused execution by Group
65
+ # Policy on a managed machine ("spawn UNKNOWN" with the binary present and complete),
66
+ # so recording the win32 half of a baseline pair requires PLAYWRIGHT_BROWSERS_PATH
67
+ # pointing somewhere policy allows. 700MB, and nothing in the repo should ever carry it.
68
+ apps/workbench/.playwright-browsers/
69
+
70
+ # Agent-session git worktrees. Local scratch checkouts of this repository, so a `git add -A`
71
+ # would otherwise stage them as embedded repositories — which it did once, and the commit had
72
+ # to be amended.
73
+ .claude/worktrees/
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.5
2
+ Name: terp-cap-mfa
3
+ Version: 0.27.0
4
+ Summary: Terp MFA capability — a TOTP second factor with recovery codes, sealed at rest and recorded in the token that results.
5
+ Project-URL: Repository, https://github.com/AITT-NL/terp-framework
6
+ Project-URL: Changelog, https://github.com/AITT-NL/terp-framework/blob/main/CHANGELOG.md
7
+ License-Expression: Apache-2.0
8
+ Requires-Python: >=3.13
9
+ Requires-Dist: cryptography>=50
10
+ Requires-Dist: terp-core==0.27.0
@@ -0,0 +1,3 @@
1
+ {
2
+ "arch-allow-schemas-exclude-sensitive-fields": 1
3
+ }
@@ -0,0 +1,41 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "terp-cap-mfa"
7
+ version = "0.27.0"
8
+ description = "Terp MFA capability — a TOTP second factor with recovery codes, sealed at rest and recorded in the token that results."
9
+ requires-python = ">=3.13"
10
+ license = "Apache-2.0"
11
+ dependencies = [
12
+ "terp-core==0.27.0",
13
+ # The enrolment secret is sealed at rest with the same Fernet-over-HKDF shape the
14
+ # webhooks capability uses for its signing secret, under an MFA-specific label.
15
+ "cryptography>=50",
16
+ ]
17
+
18
+ # A LIBRARY capability: it owns tables and a router, and declares NO `terp.capabilities`
19
+ # auto-discovery entry point. A second factor is not something an application should
20
+ # acquire by installing a package — the login route has to consult it, which is an
21
+ # explicit wiring decision (`build_login_module(second_factor=...)`), and mounting
22
+ # enrolment in an app whose login never checks a factor would publish a control that
23
+ # does nothing.
24
+
25
+ # Owns the `mfa_enrolment` and `mfa_recovery_code` tables, so it ships an independent,
26
+ # linear Alembic history (its own `alembic_version_mfa` table). `terp migrate` discovers
27
+ # this via the `terp.migrations` group (ADR 0027).
28
+ [project.entry-points."terp.migrations"]
29
+ mfa = "terp.capabilities.mfa"
30
+
31
+ # PEP 420 namespace package: this distribution owns only `terp.capabilities.mfa`.
32
+ [tool.hatch.build.targets.wheel]
33
+ sources = ["src"]
34
+ only-include = ["src/terp/capabilities/mfa"]
35
+
36
+ # Where this package comes from. The shipped changelog (`terp guide changelog`)
37
+ # ends at the installed version; the notes for a release you do not have yet
38
+ # live at these URLs, which `pip show` and the index page both surface.
39
+ [project.urls]
40
+ Repository = "https://github.com/AITT-NL/terp-framework"
41
+ Changelog = "https://github.com/AITT-NL/terp-framework/blob/main/CHANGELOG.md"
@@ -0,0 +1,105 @@
1
+ """terp.capabilities.mfa — a TOTP second factor, with recovery codes (ADR 0151).
2
+
3
+ Authentication in this platform was password-only, with OIDC as the single way to
4
+ delegate a second factor to somebody else's identity provider. An application whose most
5
+ dangerous surface is reached by an ordinary admin password — the parameters and
6
+ credential references that touch production systems — had no answer to offer.
7
+
8
+ This capability is that answer, and it is deliberately the smaller half of one:
9
+
10
+ * :mod:`~terp.capabilities.mfa.totp` implements RFC 6238 on the standard library, and is
11
+ held to the specification's own **test vectors** rather than to its author's
12
+ confidence.
13
+ * :mod:`~terp.capabilities.mfa.sealing` seals the shared secret at rest under an
14
+ MFA-specific HKDF label. The secret *is* the factor and is never rotated by the person
15
+ who owns it, so a plaintext leak hands over every enrolled account silently.
16
+ * :mod:`~terp.capabilities.mfa.recovery` issues single-use recovery codes, shown once and
17
+ stored as digests — because a second factor with no way back in is a way to lose an
18
+ account, and the support process that grows around that gap is weaker than the factor.
19
+ * The self-scoped router at ``/api/v1/mfa`` sets a factor up, proves it, reports it and
20
+ removes it. Every route acts on the caller's own account and none takes a subject id.
21
+
22
+ **Enrolment is two steps on purpose.** A secret is issued, and gates nothing until a code
23
+ generated from it comes back. A secret that was mis-scanned and treated as live is a
24
+ lockout at the next login, which is the failure that makes people turn the feature off.
25
+
26
+ A **library** capability: it declares no ``terp.capabilities`` auto-discovery entry point.
27
+ A second factor is not something an app should acquire by installing a package — the
28
+ login route has to consult it, which is an explicit wiring decision — and mounting
29
+ enrolment in an app whose login never checks a factor would publish a control that does
30
+ nothing.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ from terp.capabilities.mfa.models import MfaEnrolment, MfaRecoveryCode
36
+ from terp.capabilities.mfa.operations import (
37
+ MFA_CONFIRM,
38
+ MFA_DISABLE,
39
+ MFA_ENROL,
40
+ MFA_OPERATIONS,
41
+ MFA_STATUS,
42
+ )
43
+ from terp.capabilities.mfa.recovery import CODE_COUNT, generate_codes
44
+ from terp.capabilities.mfa.router import (
45
+ DEFAULT_ISSUER,
46
+ active_mfa_issuer,
47
+ build_mfa_module,
48
+ configure_mfa_issuer,
49
+ module,
50
+ reset_mfa_issuer,
51
+ router,
52
+ )
53
+ from terp.capabilities.mfa.schemas import (
54
+ MfaCodeRequest,
55
+ MfaEnrolmentCreate,
56
+ MfaEnrolmentRead,
57
+ MfaEnrolmentUpdate,
58
+ MfaSecretIssued,
59
+ MfaStatusRead,
60
+ )
61
+ from terp.capabilities.mfa.sealing import (
62
+ MfaSecretError,
63
+ is_sealed_secret,
64
+ seal_secret,
65
+ unseal_secret,
66
+ )
67
+ from terp.capabilities.mfa.service import (
68
+ MfaAlreadyEnrolledError,
69
+ MfaCodeInvalidError,
70
+ MfaNotEnrolledError,
71
+ MfaService,
72
+ )
73
+
74
+ __all__ = [
75
+ "CODE_COUNT",
76
+ "DEFAULT_ISSUER",
77
+ "MFA_CONFIRM",
78
+ "MFA_DISABLE",
79
+ "MFA_ENROL",
80
+ "MFA_OPERATIONS",
81
+ "MFA_STATUS",
82
+ "MfaAlreadyEnrolledError",
83
+ "MfaCodeInvalidError",
84
+ "MfaCodeRequest",
85
+ "MfaEnrolment",
86
+ "MfaEnrolmentCreate",
87
+ "MfaEnrolmentRead",
88
+ "MfaEnrolmentUpdate",
89
+ "MfaNotEnrolledError",
90
+ "MfaRecoveryCode",
91
+ "MfaSecretError",
92
+ "MfaSecretIssued",
93
+ "MfaService",
94
+ "MfaStatusRead",
95
+ "active_mfa_issuer",
96
+ "build_mfa_module",
97
+ "configure_mfa_issuer",
98
+ "generate_codes",
99
+ "is_sealed_secret",
100
+ "module",
101
+ "reset_mfa_issuer",
102
+ "router",
103
+ "seal_secret",
104
+ "unseal_secret",
105
+ ]
@@ -0,0 +1,75 @@
1
+ """create mfa enrolment and recovery-code tables
2
+
3
+ Revision ID: a3f9c1e7b204
4
+ Revises:
5
+ Create Date: 2026-09-18 15:10:00.000000
6
+
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Sequence
11
+
12
+ from alembic import op
13
+ import sqlalchemy as sa
14
+ import sqlmodel
15
+
16
+
17
+ # revision identifiers, used by Alembic.
18
+ revision: str = 'a3f9c1e7b204'
19
+ down_revision: str | None = None
20
+ branch_labels: str | Sequence[str] | None = None
21
+ depends_on: str | Sequence[str] | None = None
22
+
23
+
24
+ def upgrade() -> None:
25
+ op.create_table(
26
+ 'mfa_enrolment',
27
+ sa.Column('id', sa.Uuid(), nullable=False),
28
+ sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
29
+ sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
30
+ sa.Column('version', sa.Integer(), nullable=False),
31
+ sa.Column('user_id', sa.Uuid(), nullable=False),
32
+ sa.Column('secret', sqlmodel.sql.sqltypes.AutoString(length=512), nullable=False),
33
+ sa.Column('confirmed_at', sa.DateTime(timezone=True), nullable=True),
34
+ sa.Column('last_used_step', sa.Integer(), nullable=True),
35
+ sa.PrimaryKeyConstraint('id'),
36
+ )
37
+ op.create_index(op.f('ix_mfa_enrolment_user_id'), 'mfa_enrolment', ['user_id'], unique=True)
38
+ # Indexed because the login path asks "is this subject enrolled and live?" on every
39
+ # attempt, and a started-but-unconfirmed row must not answer yes.
40
+ op.create_index(
41
+ op.f('ix_mfa_enrolment_confirmed_at'), 'mfa_enrolment', ['confirmed_at'], unique=False
42
+ )
43
+
44
+ op.create_table(
45
+ 'mfa_recovery_code',
46
+ sa.Column('id', sa.Uuid(), nullable=False),
47
+ sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
48
+ sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
49
+ sa.Column('version', sa.Integer(), nullable=False),
50
+ sa.Column('enrolment_id', sa.Uuid(), nullable=False),
51
+ sa.Column('code_hash', sqlmodel.sql.sqltypes.AutoString(length=64), nullable=False),
52
+ sa.Column('used_at', sa.DateTime(timezone=True), nullable=True),
53
+ # CASCADE: a recovery code is a PART of its enrolment, so disabling the factor
54
+ # destroys the codes with it and leaves nothing usable behind.
55
+ sa.ForeignKeyConstraint(['enrolment_id'], ['mfa_enrolment.id'], ondelete='CASCADE'),
56
+ sa.PrimaryKeyConstraint('id'),
57
+ )
58
+ op.create_index(
59
+ op.f('ix_mfa_recovery_code_enrolment_id'),
60
+ 'mfa_recovery_code',
61
+ ['enrolment_id'],
62
+ unique=False,
63
+ )
64
+ op.create_index(
65
+ op.f('ix_mfa_recovery_code_code_hash'), 'mfa_recovery_code', ['code_hash'], unique=False
66
+ )
67
+
68
+
69
+ def downgrade() -> None:
70
+ op.drop_index(op.f('ix_mfa_recovery_code_code_hash'), table_name='mfa_recovery_code')
71
+ op.drop_index(op.f('ix_mfa_recovery_code_enrolment_id'), table_name='mfa_recovery_code')
72
+ op.drop_table('mfa_recovery_code')
73
+ op.drop_index(op.f('ix_mfa_enrolment_confirmed_at'), table_name='mfa_enrolment')
74
+ op.drop_index(op.f('ix_mfa_enrolment_user_id'), table_name='mfa_enrolment')
75
+ op.drop_table('mfa_enrolment')
@@ -0,0 +1,86 @@
1
+ """The two tables a second factor needs: one enrolment per subject, and its recovery codes.
2
+
3
+ ``MfaEnrolment`` is keyed by ``user_id`` and **unique** on it. One subject has one second
4
+ factor: a second row would mean two secrets both valid, which is a factor that cannot be
5
+ revoked by replacing it.
6
+
7
+ ``confirmed_at`` is what separates a *started* enrolment from a *live* one, and it is the
8
+ reason enrolment is two steps rather than one. A secret issued but never proved is not a
9
+ second factor — the person may have mistyped it into their authenticator, or scanned
10
+ nothing at all — and treating it as live would lock them out of their own account at the
11
+ next login. So the row exists from the moment the secret is issued (it has to: the server
12
+ must remember what it offered), and it gates nothing until a code generated from it comes
13
+ back.
14
+
15
+ ``MfaRecoveryCode`` holds one row per issued code, storing a digest and never the code.
16
+ ``used_at`` stamps consumption rather than deleting the row: a spent recovery code is
17
+ something an operator wants to see in the trail, and a deleted row answers no questions.
18
+ The reference to the enrolment declares ``CASCADE`` because a recovery code is a *part*
19
+ of its enrolment — disabling the factor destroys the codes with it, which is the one
20
+ behaviour that leaves nothing usable behind.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import datetime
26
+ import uuid
27
+ from typing import Final
28
+
29
+ from sqlalchemy import DateTime
30
+ from sqlmodel import Field
31
+
32
+ from terp.core import BaseTable, OnDelete, Ref
33
+
34
+ from terp.capabilities.mfa.recovery import CODE_HASH_LENGTH
35
+
36
+ #: Caps every caller-independent string column. The secret is sealed before it is
37
+ #: stored, and Fernet output is comfortably inside this.
38
+ SECRET_MAX: Final[int] = 512
39
+
40
+
41
+ class MfaEnrolment(BaseTable, table=True):
42
+ """One subject's second factor: the sealed shared secret and whether it is live.
43
+
44
+ ``id`` / ``created_at`` / ``updated_at`` / ``version`` are inherited from
45
+ ``BaseTable``. ``secret`` is **never** serialised out of the API boundary — no read
46
+ DTO carries it, and the enrolment response returns the plaintext exactly once, at
47
+ the moment it is generated, because an authenticator app has to be given it.
48
+ """
49
+
50
+ __tablename__ = "mfa_enrolment"
51
+
52
+ user_id: uuid.UUID = Field(index=True, unique=True)
53
+ secret: str = Field(max_length=SECRET_MAX)
54
+ #: ``None`` until a code generated from the secret comes back. A started enrolment
55
+ #: gates nothing; see the module docstring for why that is not an oversight.
56
+ confirmed_at: datetime.datetime | None = Field( # type: ignore[call-overload]
57
+ default=None,
58
+ sa_type=DateTime(timezone=True),
59
+ nullable=True,
60
+ index=True,
61
+ )
62
+
63
+ #: The TOTP step a successful verification last spent. A code is valid for its whole
64
+ #: window, so without this the same six digits authenticate again for as long as that
65
+ #: window lasts -- ninety seconds at the default drift -- and anyone who saw them once
66
+ #: can use them. RFC 6238 section 5.2 puts the duty to refuse the second use on the
67
+ #: verifier, and this column is the verifier's memory. ``None`` until the first
68
+ #: successful verification, which is also why it cannot be inferred from ``updated_at``.
69
+ last_used_step: int | None = Field(default=None, nullable=True)
70
+
71
+
72
+ class MfaRecoveryCode(BaseTable, table=True):
73
+ """One single-use recovery code, stored as a digest and stamped when spent."""
74
+
75
+ __tablename__ = "mfa_recovery_code"
76
+
77
+ enrolment_id: uuid.UUID = Ref("mfa_enrolment.id", on_delete=OnDelete.CASCADE, index=True)
78
+ code_hash: str = Field(max_length=CODE_HASH_LENGTH, index=True)
79
+ used_at: datetime.datetime | None = Field( # type: ignore[call-overload]
80
+ default=None,
81
+ sa_type=DateTime(timezone=True),
82
+ nullable=True,
83
+ )
84
+
85
+
86
+ __all__ = ["SECRET_MAX", "MfaEnrolment", "MfaRecoveryCode"]
@@ -0,0 +1,41 @@
1
+ """Operation declarations for the ``mfa`` capability's self-scoped routes.
2
+
3
+ Each route declares what it does in plain English (ADR 0102), so a non-technical reader
4
+ sees the effect of setting up, proving, inspecting or removing a second factor without
5
+ reading HTTP verbs and paths.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from terp.core import OperationDefinition
11
+
12
+ MFA_STATUS = OperationDefinition(
13
+ id="mfa.status", label="Check whether a second factor is set up"
14
+ )
15
+ MFA_ENROL = OperationDefinition(
16
+ id="mfa.enrol", label="Start setting up a second factor"
17
+ )
18
+ MFA_CONFIRM = OperationDefinition(
19
+ id="mfa.confirm", label="Finish setting up a second factor"
20
+ )
21
+ MFA_DISABLE = OperationDefinition(id="mfa.disable", label="Remove the second factor")
22
+
23
+ #: Every operation this capability's routes declare, in declaration order.
24
+ #:
25
+ #: An app folds the capability into its :class:`~terp.core.OperationCatalog` by splatting
26
+ #: this (``*MFA_OPERATIONS``) rather than naming each constant, so a release that adds a
27
+ #: route here cannot refuse a ``STRICT`` app's boot (ADR 0126).
28
+ MFA_OPERATIONS: tuple[OperationDefinition, ...] = (
29
+ MFA_STATUS,
30
+ MFA_ENROL,
31
+ MFA_CONFIRM,
32
+ MFA_DISABLE,
33
+ )
34
+
35
+ __all__ = [
36
+ "MFA_CONFIRM",
37
+ "MFA_DISABLE",
38
+ "MFA_ENROL",
39
+ "MFA_OPERATIONS",
40
+ "MFA_STATUS",
41
+ ]
File without changes
@@ -0,0 +1,88 @@
1
+ """Recovery codes: the way back in when the phone is gone.
2
+
3
+ A second factor that cannot be recovered is a way to lose an account, and the support
4
+ process that grows around that gap — "ring us and we will turn it off" — is a social-
5
+ engineering surface far weaker than the factor it rescues. So enrolment issues a fixed
6
+ set of single-use codes, shown once.
7
+
8
+ Three properties, each of which is the whole point of that property:
9
+
10
+ * **Shown once, stored hashed.** The row holds ``sha256`` of the code, never the code.
11
+ A plain hash is right here and a slow KDF would be wrong: these are 80-bit random
12
+ strings this module generated, not human-chosen passwords, so there is no dictionary
13
+ to mount and nothing for a work factor to buy. (The enrolment *secret* is sealed
14
+ rather than hashed for the opposite reason — verification needs to reproduce it.)
15
+ * **Single use.** A code that still worked after being used is a password with extra
16
+ steps. Consumption stamps the row, and the stamped row stays for the trail.
17
+ * **Constant-time comparison**, over every stored code. Hashes make timing much less
18
+ interesting than it is for the TOTP code, but the loop costs nothing and the habit is
19
+ the thing that survives a later change to the format.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import hashlib
25
+ import hmac
26
+ import secrets
27
+ from typing import Final
28
+
29
+ #: How many codes an enrolment issues. Ten is the common default: enough that losing a
30
+ #: printout is survivable, few enough that a person keeps them somewhere deliberate.
31
+ CODE_COUNT: Final[int] = 10
32
+
33
+ #: Bytes of entropy per code (80 bits), rendered as base32 without padding.
34
+ _CODE_BYTES: Final[int] = 10
35
+
36
+ #: The stored digest's width, mirrored by the column cap.
37
+ CODE_HASH_LENGTH: Final[int] = 64
38
+
39
+
40
+ def generate_codes(count: int = CODE_COUNT) -> list[str]:
41
+ """*count* fresh recovery codes, in the form the person is shown once.
42
+
43
+ Grouped with a hyphen because these get written down and read back by hand, and an
44
+ unbroken sixteen-character string is where transcription errors come from.
45
+ """
46
+ codes: list[str] = []
47
+ for _ in range(count):
48
+ raw = base32(secrets.token_bytes(_CODE_BYTES))
49
+ codes.append(f"{raw[:4]}-{raw[4:8]}-{raw[8:12]}-{raw[12:16]}")
50
+ return codes
51
+
52
+
53
+ def base32(value: bytes) -> str:
54
+ """Unpadded, upper-case base32 — the alphabet people can read back reliably."""
55
+ import base64
56
+
57
+ return base64.b32encode(value).decode("ascii").rstrip("=")
58
+
59
+
60
+ def normalise(code: str) -> str:
61
+ """The comparable form of a typed code: no spaces or hyphens, upper-case.
62
+
63
+ People retype these from paper, so the separators and the case are presentation
64
+ rather than content. Normalising at the boundary keeps that judgement in one place
65
+ instead of at every comparison.
66
+ """
67
+ return code.strip().replace("-", "").replace(" ", "").upper()
68
+
69
+
70
+ def hash_code(code: str) -> str:
71
+ """The stored digest of *code*."""
72
+ return hashlib.sha256(normalise(code).encode("utf-8")).hexdigest()
73
+
74
+
75
+ def matches(code: str, stored_hash: str) -> bool:
76
+ """Whether *code* hashes to *stored_hash*, compared in constant time."""
77
+ return hmac.compare_digest(hash_code(code), stored_hash)
78
+
79
+
80
+ __all__ = [
81
+ "CODE_COUNT",
82
+ "CODE_HASH_LENGTH",
83
+ "base32",
84
+ "generate_codes",
85
+ "hash_code",
86
+ "matches",
87
+ "normalise",
88
+ ]
@@ -0,0 +1,194 @@
1
+ """The self-scoped second-factor router: set one up, prove it, inspect it, remove it.
2
+
3
+ Every route here acts on **the caller's own** account, read from the authenticated
4
+ principal. None takes a subject id, so none can be turned into a way to enrol, inspect or
5
+ disable somebody else's factor — the shape ``GET /me`` uses, and for the same reason: an
6
+ id parameter on a self-scoped route is an object-level authorization bug waiting for the
7
+ first person who tries another id.
8
+
9
+ Mounted at the VIEWER tier for both reads and writes: any authenticated caller manages
10
+ their own factor, because a read-only account is not a second-class one.
11
+ There is deliberately no administrative *disable* route. An operator who can turn off
12
+ somebody else's second factor is the weakest link in the control, and support-desk
13
+ disabling is the social-engineering path that defeats MFA in practice — a person who has
14
+ lost their phone uses a recovery code, which is why the enrolment issues them.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from typing import Final
20
+
21
+ from fastapi import APIRouter, Depends
22
+
23
+ from terp.core import (
24
+ AuthenticationError,
25
+ ModuleSpec,
26
+ Policy,
27
+ Principal,
28
+ Roles,
29
+ SessionDep,
30
+ get_principal,
31
+ operation,
32
+ )
33
+
34
+ from terp.capabilities.mfa.operations import (
35
+ MFA_CONFIRM,
36
+ MFA_DISABLE,
37
+ MFA_ENROL,
38
+ MFA_STATUS,
39
+ )
40
+ from terp.capabilities.mfa.schemas import (
41
+ MfaCodeRequest,
42
+ MfaEnrolmentRead,
43
+ MfaSecretIssued,
44
+ MfaStatusRead,
45
+ )
46
+ from terp.capabilities.mfa.service import MfaService
47
+
48
+ router = APIRouter(tags=["mfa"])
49
+ _service = MfaService()
50
+
51
+ #: The label an authenticator app shows beside the generated codes. A person may hold
52
+ #: accounts in several systems, and the issuer is the only thing distinguishing one
53
+ #: six-digit row from another, so it is the deployment's own name — the framework cannot
54
+ #: know it and declines to guess, defaulting to its own name rather than inventing one.
55
+ DEFAULT_ISSUER: Final[str] = "Terp"
56
+
57
+ # The active issuer (module-level, composition-root-configured — the same seam shape the
58
+ # files capability uses for its upload limit). Never client data: it is baked into every
59
+ # enrolment QR code, so a caller who could set it could make their enrolment impersonate
60
+ # another system in the victim's authenticator app.
61
+ _issuer: str = DEFAULT_ISSUER
62
+
63
+
64
+ def configure_mfa_issuer(name: str) -> None:
65
+ """Name the deployment in the codes its people enrol (a composition-root line).
66
+
67
+ Validated eagerly so a mis-wired root fails at boot rather than at the first
68
+ enrolment — and a blank issuer is the failure worth catching, because it produces a
69
+ QR code that scans cleanly and then shows up in the authenticator app as an unlabelled
70
+ row the person cannot tell from any other.
71
+ """
72
+ global _issuer
73
+ cleaned = name.strip()
74
+ if not cleaned:
75
+ raise ValueError("the MFA issuer must be a non-empty name")
76
+ _issuer = cleaned
77
+
78
+
79
+ def active_mfa_issuer() -> str:
80
+ """The issuer new enrolments are currently labelled with."""
81
+ return _issuer
82
+
83
+
84
+ def reset_mfa_issuer() -> None:
85
+ """Restore the default issuer (the test-isolation reset)."""
86
+ global _issuer
87
+ _issuer = DEFAULT_ISSUER
88
+
89
+
90
+ def _caller(principal: Principal | None) -> Principal:
91
+ """The authenticated caller, or a clean 401.
92
+
93
+ The module guard rejects an anonymous caller before any handler runs; this keeps the
94
+ router correct — a 401, never an ``AttributeError`` — if it is ever mounted without
95
+ one.
96
+ """
97
+ if principal is None:
98
+ raise AuthenticationError()
99
+ return principal
100
+
101
+
102
+ @router.get("/", response_model=MfaStatusRead)
103
+ @operation(MFA_STATUS)
104
+ def read_status(
105
+ session: SessionDep, principal: Principal | None = Depends(get_principal)
106
+ ) -> MfaStatusRead:
107
+ from sqlmodel import select
108
+
109
+ from terp.capabilities.mfa.models import MfaRecoveryCode
110
+
111
+ caller = _caller(principal)
112
+ enrolment = _service.enrolment_for(session, caller.id)
113
+ remaining = 0
114
+ if enrolment is not None:
115
+ remaining = len(
116
+ session.exec(
117
+ select(MfaRecoveryCode).where(
118
+ MfaRecoveryCode.enrolment_id == enrolment.id,
119
+ MfaRecoveryCode.used_at.is_(None), # type: ignore[union-attr]
120
+ )
121
+ ).all()
122
+ )
123
+ return MfaStatusRead(
124
+ enrolled=enrolment is not None,
125
+ confirmed=enrolment is not None and enrolment.confirmed_at is not None,
126
+ recovery_codes_remaining=remaining,
127
+ )
128
+
129
+
130
+ @router.post("/", response_model=MfaSecretIssued, status_code=201)
131
+ @operation(MFA_ENROL)
132
+ def begin_enrolment(
133
+ session: SessionDep, principal: Principal | None = Depends(get_principal)
134
+ ) -> MfaSecretIssued:
135
+ # The only response in this capability that carries a plaintext secret, and the only
136
+ # moment one crosses the boundary: an authenticator app cannot be enrolled without
137
+ # it. It is not stored in this form and no read DTO can return it again.
138
+ caller = _caller(principal)
139
+ return _service.begin_enrolment(
140
+ session,
141
+ caller.id,
142
+ account=str(caller.id),
143
+ issuer=active_mfa_issuer(),
144
+ )
145
+
146
+
147
+ @router.post("/confirm", response_model=MfaEnrolmentRead)
148
+ @operation(MFA_CONFIRM)
149
+ def confirm_enrolment(
150
+ payload: MfaCodeRequest,
151
+ session: SessionDep,
152
+ principal: Principal | None = Depends(get_principal),
153
+ ) -> MfaEnrolmentRead:
154
+ caller = _caller(principal)
155
+ return MfaEnrolmentRead.model_validate(
156
+ _service.confirm_enrolment(session, caller.id, payload.code)
157
+ )
158
+
159
+
160
+ @router.delete("/", status_code=204)
161
+ @operation(MFA_DISABLE)
162
+ def disable(
163
+ session: SessionDep, principal: Principal | None = Depends(get_principal)
164
+ ) -> None:
165
+ caller = _caller(principal)
166
+ _service.disable(session, caller.id)
167
+
168
+
169
+ def build_mfa_module(*, name: str = "mfa") -> ModuleSpec:
170
+ """The self-scoped second-factor ``ModuleSpec`` (any authenticated caller).
171
+
172
+ VIEWER on the write tier, not the EDITOR a bare ``Policy.default()`` would impose.
173
+ Enrolling is a POST, but it changes nothing except the caller's own security, and a
174
+ read-only account is not a second-class one: gating this at EDITOR would deny a
175
+ second factor to exactly the accounts an application hands out most freely, which
176
+ inverts the control.
177
+ """
178
+ return ModuleSpec(
179
+ name=name, router=router, policy=Policy(read=Roles.VIEWER, write=Roles.VIEWER)
180
+ )
181
+
182
+
183
+ module = build_mfa_module()
184
+
185
+
186
+ __all__ = [
187
+ "DEFAULT_ISSUER",
188
+ "active_mfa_issuer",
189
+ "build_mfa_module",
190
+ "configure_mfa_issuer",
191
+ "module",
192
+ "reset_mfa_issuer",
193
+ "router",
194
+ ]
@@ -0,0 +1,88 @@
1
+ """MFA DTOs.
2
+
3
+ ``MfaEnrolmentCreate`` is **server-built only**: the service generates the secret and
4
+ seals it before constructing this, so a client never supplies either. ``MfaEnrolmentRead``
5
+ carries no secret at all — not sealed, not masked, not present. The one moment a
6
+ plaintext secret crosses the boundary is :class:`MfaSecretIssued`, returned exactly once
7
+ from the enrolment call, because an authenticator app cannot be enrolled without it.
8
+
9
+ ``MfaSecretIssued`` is deliberately not a read DTO of the row. It is the response to one
10
+ action, and it exists as its own type so nothing can be tempted to return it from a
11
+ listing later.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import datetime
17
+ import uuid
18
+
19
+ from sqlmodel import Field
20
+
21
+ from terp.core import BaseSchema, BaseUpdateSchema
22
+
23
+ from terp.capabilities.mfa.models import SECRET_MAX
24
+
25
+ _CODE_MAX = 32
26
+
27
+
28
+ class MfaEnrolmentCreate(BaseSchema):
29
+ """The enrolment row — constructed by the service, never posted by a client."""
30
+
31
+ user_id: uuid.UUID
32
+ secret: str = Field(min_length=1, max_length=SECRET_MAX)
33
+
34
+
35
+ class MfaEnrolmentUpdate(BaseUpdateSchema):
36
+ """Patch the enrolment's live-ness (OCC via the inherited required ``version``).
37
+
38
+ Only ``confirmed_at`` is patchable, and only the service sets it. The secret is
39
+ append-only: replacing a factor means disabling it and enrolling again, which is two
40
+ audited acts rather than one silent one.
41
+ """
42
+
43
+ confirmed_at: datetime.datetime | None = None
44
+ last_used_step: int | None = None
45
+
46
+
47
+ class MfaEnrolmentRead(BaseSchema):
48
+ """What a caller may learn about their own enrolment: that it exists, and whether it is live."""
49
+
50
+ id: uuid.UUID
51
+ user_id: uuid.UUID
52
+ confirmed_at: datetime.datetime | None
53
+ version: int
54
+ created_at: datetime.datetime
55
+ updated_at: datetime.datetime
56
+
57
+
58
+ class MfaSecretIssued(BaseSchema):
59
+ """The one-time response to starting an enrolment: the secret, its URI, the codes."""
60
+
61
+ # arch-allow-schemas-exclude-sensitive-fields: an authenticator app cannot be enrolled without the shared secret, so this one response must carry it. It is returned exactly once, at the moment it is generated, never read back from the row (which holds it sealed), and carried by no other DTO -- MfaEnrolmentRead has no secret field at all.
62
+ secret: str
63
+ provisioning_uri: str
64
+ recovery_codes: list[str]
65
+
66
+
67
+ class MfaCodeRequest(BaseSchema):
68
+ """A submitted code — a TOTP digit string or a recovery code."""
69
+
70
+ code: str = Field(min_length=1, max_length=_CODE_MAX)
71
+
72
+
73
+ class MfaStatusRead(BaseSchema):
74
+ """Whether the caller has a live second factor, and how many recovery codes remain."""
75
+
76
+ enrolled: bool
77
+ confirmed: bool
78
+ recovery_codes_remaining: int
79
+
80
+
81
+ __all__ = [
82
+ "MfaCodeRequest",
83
+ "MfaEnrolmentCreate",
84
+ "MfaEnrolmentRead",
85
+ "MfaEnrolmentUpdate",
86
+ "MfaSecretIssued",
87
+ "MfaStatusRead",
88
+ ]
@@ -0,0 +1,91 @@
1
+ """At-rest sealing for the TOTP shared secret.
2
+
3
+ The enrolment secret *is* the second factor: anyone holding it can generate the codes
4
+ for that account forever, and unlike a password it is never rotated by the person who
5
+ owns it. A database leak of plaintext secrets would therefore hand over every enrolled
6
+ account's second factor silently — the victims' phones keep producing the same codes the
7
+ attacker now produces, so nothing about the account looks wrong.
8
+
9
+ The cipher is a deliberate sibling of ``terp.capabilities.webhooks.sealing`` rather than
10
+ a call into it: Fernet (AES128-CBC + HMAC-SHA256) keyed from the live ``SECRET_KEY``
11
+ through HKDF with an **MFA-specific** ``info`` label. Domain separation is the point — a
12
+ sealed value from one domain must not decrypt in another, so a leaked webhook secret is
13
+ not an enrolment secret and neither is a sealed config value.
14
+
15
+ Unlike the webhook secret there is **no legacy tolerance**. That capability passes an
16
+ unsealed value through unchanged, because rows predating its sealing control exist and
17
+ must keep delivering. No row here predates this control, so a secret that does not
18
+ carry the sealed prefix is not a legacy value — it is a bug or a tampered row, and
19
+ reading it as a valid factor would be the one mistake this module exists to prevent.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import base64
25
+ from typing import Final
26
+
27
+ from terp.core import AppError, get_settings
28
+
29
+ #: The portable sealed format (shared shape with ``terp.core.secrets``, distinct key).
30
+ _SEAL_PREFIX: Final[str] = "enc:v1:"
31
+
32
+ # Domain separation: the MFA-seal key is derived from SECRET_KEY with this label, never
33
+ # SECRET_KEY itself and never another capability's derivation.
34
+ _HKDF_INFO: Final[bytes] = b"terp.capabilities.mfa.totp-seal.v1"
35
+
36
+
37
+ class MfaSecretError(AppError):
38
+ """500 — an enrolment secret could not be read under the current ``SECRET_KEY``.
39
+
40
+ Deliberately a 500 and deliberately not "invalid code": the caller did nothing
41
+ wrong, and reporting it as a failed verification would tell an operator their users
42
+ are typing the wrong digits while the real fault is a rotated key or a damaged row.
43
+ """
44
+
45
+ status_code = 500
46
+ code = "mfa_secret_error"
47
+ default_message = "The second-factor enrolment could not be read."
48
+
49
+
50
+ def _cipher(): # the return type lives in the `cryptography` dependency
51
+ """The sealing cipher, keyed from the live ``SECRET_KEY`` via HKDF."""
52
+ from cryptography.fernet import Fernet
53
+ from cryptography.hazmat.primitives import hashes
54
+ from cryptography.hazmat.primitives.kdf.hkdf import HKDF
55
+
56
+ derived = HKDF(
57
+ algorithm=hashes.SHA256(), length=32, salt=None, info=_HKDF_INFO
58
+ ).derive(get_settings().SECRET_KEY.encode("utf-8"))
59
+ return Fernet(base64.urlsafe_b64encode(derived))
60
+
61
+
62
+ def is_sealed_secret(value: str) -> bool:
63
+ """Whether *value* carries the sealed ``enc:v1:`` format."""
64
+ return value.startswith(_SEAL_PREFIX)
65
+
66
+
67
+ def seal_secret(plaintext: str) -> str:
68
+ """Seal a TOTP shared secret for at-rest storage."""
69
+ token = _cipher().encrypt(plaintext.encode("utf-8"))
70
+ return _SEAL_PREFIX + token.decode("ascii")
71
+
72
+
73
+ def unseal_secret(stored: str) -> str:
74
+ """The verification-time plaintext of *stored* — fail-closed on anything else.
75
+
76
+ A value with no sealed prefix raises rather than being used: see the module
77
+ docstring for why this capability has no legacy-plaintext path.
78
+ """
79
+ if not is_sealed_secret(stored):
80
+ raise MfaSecretError()
81
+ from cryptography.fernet import InvalidToken
82
+
83
+ try:
84
+ return (
85
+ _cipher().decrypt(stored.removeprefix(_SEAL_PREFIX).encode("ascii")).decode("utf-8")
86
+ )
87
+ except InvalidToken as exc:
88
+ raise MfaSecretError() from exc
89
+
90
+
91
+ __all__ = ["MfaSecretError", "is_sealed_secret", "seal_secret", "unseal_secret"]
@@ -0,0 +1,247 @@
1
+ """The second-factor service: enrol, confirm, verify, disable.
2
+
3
+ Every write here goes through :class:`~terp.core.BaseService`'s audited chokepoint, so
4
+ enrolling and disabling a factor land in the trail like any other change. That matters
5
+ more than usual for this table: *disabling* a second factor is the single most useful
6
+ thing an attacker who has taken an account can do to keep it, and a disable that left no
7
+ record would be invisible.
8
+
9
+ Verifying is a **write too**, which reads as a surprise and is not one: a second factor
10
+ that can be used twice is not a second factor, so accepting a code has to record that it
11
+ was accepted. A TOTP code moves the enrolment's high-water mark; a recovery code is
12
+ stamped spent. Both go through the same chokepoint as everything else here — the
13
+ ``mutations_emit_audit`` rule requires it, and a factor being exercised is a thing the
14
+ trail should carry. It happens during a login, before there is a session at all, so the
15
+ actor on those records is the login rather than a signed-in person.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import datetime
21
+ import uuid
22
+
23
+ from sqlmodel import Session, select
24
+
25
+ from terp.core import AppError, BaseService
26
+
27
+ from terp.capabilities.mfa import recovery, totp
28
+ from terp.capabilities.mfa.models import MfaEnrolment, MfaRecoveryCode
29
+ from terp.capabilities.mfa.schemas import (
30
+ MfaEnrolmentCreate,
31
+ MfaEnrolmentUpdate,
32
+ MfaSecretIssued,
33
+ )
34
+ from terp.capabilities.mfa.sealing import seal_secret, unseal_secret
35
+
36
+
37
+ class MfaAlreadyEnrolledError(AppError):
38
+ """409 — this subject already has a live second factor.
39
+
40
+ Refused rather than silently replaced: overwriting a live enrolment is exactly the
41
+ move an attacker makes with a stolen session, and it would read as an ordinary
42
+ "set up authenticator" request. Replacing one means disabling it first, which is a
43
+ separate, audited act.
44
+ """
45
+
46
+ status_code = 409
47
+ code = "mfa_already_enrolled"
48
+ default_message = "A second factor is already enrolled for this account."
49
+
50
+
51
+ class MfaNotEnrolledError(AppError):
52
+ """404 — there is no enrolment to confirm, verify against, or disable."""
53
+
54
+ status_code = 404
55
+ code = "mfa_not_enrolled"
56
+ default_message = "No second factor is enrolled for this account."
57
+
58
+
59
+ class MfaCodeInvalidError(AppError):
60
+ """401 — the code did not verify.
61
+
62
+ One error for a wrong TOTP code and a wrong recovery code, on purpose: telling a
63
+ caller *which* of the two they got wrong tells them which one they are closer to,
64
+ and neither answer helps somebody typing their own code.
65
+ """
66
+
67
+ status_code = 401
68
+ code = "mfa_code_invalid"
69
+ default_message = "That code is not valid."
70
+
71
+
72
+ def _utc_now() -> datetime.datetime:
73
+ """UTC ``now`` provider — private so tests can drive the clock."""
74
+ return datetime.datetime.now(datetime.UTC)
75
+
76
+
77
+ class MfaService(BaseService[MfaEnrolment, MfaEnrolmentCreate, MfaEnrolmentUpdate]):
78
+ model = MfaEnrolment
79
+
80
+ def enrolment_for(self, session: Session, user_id: uuid.UUID) -> MfaEnrolment | None:
81
+ """This subject's enrolment row, confirmed or not."""
82
+ return session.exec(
83
+ select(MfaEnrolment).where(MfaEnrolment.user_id == user_id)
84
+ ).first()
85
+
86
+ def is_enrolled(self, session: Session, user_id: uuid.UUID) -> bool:
87
+ """Whether a **confirmed** second factor stands between this subject and a session.
88
+
89
+ Only a confirmed enrolment counts. A started-but-unproved one must not gate a
90
+ login, or a mis-scanned QR code becomes a lockout.
91
+ """
92
+ enrolment = self.enrolment_for(session, user_id)
93
+ return enrolment is not None and enrolment.confirmed_at is not None
94
+
95
+ def begin_enrolment(
96
+ self, session: Session, user_id: uuid.UUID, *, account: str, issuer: str
97
+ ) -> MfaSecretIssued:
98
+ """Issue a secret and recovery codes. The plaintext is returned exactly once.
99
+
100
+ An *unconfirmed* row is replaced rather than refused: somebody who started an
101
+ enrolment, closed the tab and came back has no way to recover the first secret,
102
+ and refusing them would leave a row nobody can confirm and nobody can clear. A
103
+ *confirmed* row is refused (see :class:`MfaAlreadyEnrolledError`).
104
+ """
105
+ existing = self.enrolment_for(session, user_id)
106
+ if existing is not None:
107
+ if existing.confirmed_at is not None:
108
+ raise MfaAlreadyEnrolledError()
109
+ self._purge_recovery_codes(session, existing.id)
110
+ self.delete(session, existing.id)
111
+
112
+ secret = totp.generate_secret()
113
+ enrolment = self.create(
114
+ session,
115
+ MfaEnrolmentCreate(user_id=user_id, secret=seal_secret(secret)),
116
+ )
117
+ codes = recovery.generate_codes()
118
+ for code in codes:
119
+ self._save_recovery_code(session, enrolment.id, recovery.hash_code(code))
120
+ return MfaSecretIssued(
121
+ secret=secret,
122
+ provisioning_uri=totp.provisioning_uri(secret, account=account, issuer=issuer),
123
+ recovery_codes=codes,
124
+ )
125
+
126
+ def confirm_enrolment(self, session: Session, user_id: uuid.UUID, code: str) -> MfaEnrolment:
127
+ """Prove the secret arrived intact, and make the factor live."""
128
+ enrolment = self.enrolment_for(session, user_id)
129
+ if enrolment is None:
130
+ raise MfaNotEnrolledError()
131
+ if enrolment.confirmed_at is not None:
132
+ raise MfaAlreadyEnrolledError()
133
+ step = totp.verify_step(unseal_secret(enrolment.secret), code)
134
+ if step is None:
135
+ raise MfaCodeInvalidError()
136
+ # The confirming code is spent by confirming. Recording it here is what stops it
137
+ # being handed straight back as the first login factor, which is a replay across
138
+ # two endpoints rather than two calls to one.
139
+ return self.update(
140
+ session,
141
+ enrolment.id,
142
+ MfaEnrolmentUpdate(
143
+ confirmed_at=_utc_now(), last_used_step=step, version=enrolment.version
144
+ ),
145
+ )
146
+
147
+ def verify(self, session: Session, user_id: uuid.UUID, code: str) -> bool:
148
+ """Whether *code* satisfies this subject's second factor.
149
+
150
+ Accepts a TOTP code or an unspent recovery code, and spends the latter. Returns
151
+ ``False`` rather than raising for a subject with no confirmed enrolment: the
152
+ caller is the login route, and "this account has no second factor" is its
153
+ question to ask beforehand, not an error to handle here.
154
+ """
155
+ enrolment = self.enrolment_for(session, user_id)
156
+ if enrolment is None or enrolment.confirmed_at is None:
157
+ return False
158
+ step = totp.verify_step(unseal_secret(enrolment.secret), code)
159
+ if step is not None:
160
+ return self._spend_step(session, enrolment, step)
161
+ return self._consume_recovery_code(session, enrolment.id, code)
162
+
163
+ def disable(self, session: Session, user_id: uuid.UUID) -> None:
164
+ """Remove the factor and every recovery code with it (an audited delete)."""
165
+ enrolment = self.enrolment_for(session, user_id)
166
+ if enrolment is None:
167
+ raise MfaNotEnrolledError()
168
+ self._purge_recovery_codes(session, enrolment.id)
169
+ self.delete(session, enrolment.id)
170
+
171
+ def _purge_recovery_codes(self, session: Session, enrolment_id: uuid.UUID) -> None:
172
+ """Delete this enrolment's codes through the audited chokepoint.
173
+
174
+ Explicit, rather than leaning on the foreign key's ``ON DELETE CASCADE``. The
175
+ cascade is declared and is the right backstop, but it only fires where the
176
+ database enforces foreign keys -- SQLite does not, unless asked -- so a service
177
+ that relied on it would leave live recovery codes behind on one backend and not
178
+ another. A spent-looking factor whose codes still authenticate is the worst of
179
+ the available failures, so the deletion is stated here and the cascade is
180
+ defence in depth.
181
+ """
182
+ for row in session.exec(
183
+ select(MfaRecoveryCode).where(MfaRecoveryCode.enrolment_id == enrolment_id)
184
+ ).all():
185
+ self._remove(session, row)
186
+
187
+ def _save_recovery_code(
188
+ self, session: Session, enrolment_id: uuid.UUID, code_hash: str
189
+ ) -> None:
190
+ """Persist one code digest through the audited chokepoint."""
191
+ from terp.core import AuditAction
192
+
193
+ self._save(
194
+ session,
195
+ MfaRecoveryCode(enrolment_id=enrolment_id, code_hash=code_hash),
196
+ AuditAction.CREATED,
197
+ )
198
+
199
+ def _spend_step(self, session: Session, enrolment: MfaEnrolment, step: int) -> bool:
200
+ """Accept *step* once, and never again for this enrolment."""
201
+ from terp.core import AuditAction
202
+
203
+ # `<=`, not `==`: the drift window reaches one step BACK, so after a code from the
204
+ # current step is spent the previous step is still inside the window and its code
205
+ # would otherwise verify. Refusing everything at or below the high-water mark closes
206
+ # the window behind the caller rather than just the one code they used.
207
+ if enrolment.last_used_step is not None and step <= enrolment.last_used_step:
208
+ return False
209
+ enrolment.last_used_step = step
210
+ # Through the chokepoint, like every other mutation here: `mutations_emit_audit`
211
+ # requires it, and a second factor being exercised is a thing the trail should carry.
212
+ self._save(session, enrolment, AuditAction.UPDATED)
213
+ return True
214
+
215
+ def _consume_recovery_code(
216
+ self, session: Session, enrolment_id: uuid.UUID, code: str
217
+ ) -> bool:
218
+ """Spend an unspent recovery code matching *code*, if there is one.
219
+
220
+ Every candidate is compared even after a match is found, so the work does not
221
+ depend on which code was presented.
222
+ """
223
+ from terp.core import AuditAction
224
+
225
+ rows = session.exec(
226
+ select(MfaRecoveryCode).where(
227
+ MfaRecoveryCode.enrolment_id == enrolment_id,
228
+ MfaRecoveryCode.used_at.is_(None), # type: ignore[union-attr]
229
+ )
230
+ ).all()
231
+ found: MfaRecoveryCode | None = None
232
+ for row in rows:
233
+ if recovery.matches(code, row.code_hash) and found is None:
234
+ found = row
235
+ if found is None:
236
+ return False
237
+ found.used_at = _utc_now()
238
+ self._save(session, found, AuditAction.UPDATED)
239
+ return True
240
+
241
+
242
+ __all__ = [
243
+ "MfaAlreadyEnrolledError",
244
+ "MfaCodeInvalidError",
245
+ "MfaNotEnrolledError",
246
+ "MfaService",
247
+ ]
@@ -0,0 +1,159 @@
1
+ """RFC 6238 time-based one-time passwords, on the standard library.
2
+
3
+ No dependency for this. TOTP is HMAC-SHA1 over a counter derived from the clock
4
+ (RFC 4226 truncation, RFC 6238 time step) — about twenty lines against ``hmac`` and
5
+ ``hashlib``, and the specification ships **test vectors**, so correctness here is
6
+ checkable rather than asserted. Those vectors are in the suite; a change that broke the
7
+ algorithm could not stay green.
8
+
9
+ The alternative was a dependency for twenty lines of well-specified arithmetic, which is
10
+ a supply-chain edge every consumer inherits. Where that trade would go the other way is
11
+ anything requiring primitive design — and this requires none: the primitive is
12
+ ``hmac.new``, which is the standard library's.
13
+
14
+ Two properties the arithmetic does not give you, and which a caller cannot add
15
+ afterwards:
16
+
17
+ * **Comparison is constant-time.** A code is a shared secret for thirty seconds and
18
+ ``==`` on strings leaks its prefix through timing. :func:`verify` uses
19
+ ``hmac.compare_digest`` on every candidate.
20
+ * **Drift is bounded and symmetric.** Phone clocks are wrong. A window of one step
21
+ either side (±30 s by default) is the RFC's own suggestion; widening it multiplies the
22
+ codes a guesser may hit, so it is a parameter with a small default rather than
23
+ something a call site improvises.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import base64
29
+ import hashlib
30
+ import hmac
31
+ import secrets
32
+ import struct
33
+ import time
34
+ from typing import Final
35
+
36
+ #: The RFC 6238 default time step. Not configurable: an authenticator app assumes it,
37
+ #: and a deployment that changed it would produce codes no enrolled phone can generate.
38
+ TIME_STEP_SECONDS: Final[int] = 30
39
+
40
+ #: Digits in a generated code. Six is what every authenticator app shows.
41
+ DIGITS: Final[int] = 6
42
+
43
+ #: How many steps either side of the current one are accepted. One step (±30 s) covers
44
+ #: ordinary phone-clock drift and the time a person takes to type. Each extra step is
45
+ #: another code a guesser may hit, so this stays small and is stated rather than tuned.
46
+ DEFAULT_DRIFT_STEPS: Final[int] = 1
47
+
48
+ #: Bytes of entropy in a generated shared secret (160 bits, the RFC 4226 recommendation
49
+ #: for HMAC-SHA1).
50
+ _SECRET_BYTES: Final[int] = 20
51
+
52
+
53
+ def generate_secret() -> str:
54
+ """A fresh base32 shared secret, in the form an authenticator app expects."""
55
+ return base64.b32encode(secrets.token_bytes(_SECRET_BYTES)).decode("ascii").rstrip("=")
56
+
57
+
58
+ def _counter_code(secret: str, counter: int) -> str:
59
+ """The RFC 4226 HOTP value for *counter* — the whole of the arithmetic."""
60
+ # Base32 alphabets in the wild arrive unpadded and lower-cased; normalise both rather
61
+ # than making every caller remember to.
62
+ normalised = secret.strip().replace(" ", "").upper()
63
+ padding = "=" * (-len(normalised) % 8)
64
+ key = base64.b32decode(normalised + padding, casefold=True)
65
+ digest = hmac.new(key, struct.pack(">Q", counter), hashlib.sha1).digest()
66
+ # Dynamic truncation (RFC 4226 §5.3): the low nibble of the last byte picks the
67
+ # offset, and the high bit of the selected word is masked off so the result is
68
+ # positive on every platform's signed interpretation.
69
+ offset = digest[-1] & 0x0F
70
+ (truncated,) = struct.unpack(">I", digest[offset : offset + 4])
71
+ return str((truncated & 0x7FFF_FFFF) % (10**DIGITS)).zfill(DIGITS)
72
+
73
+
74
+ def generate(secret: str, *, at: float | None = None) -> str:
75
+ """The code *secret* produces now (or at *at*, a UNIX timestamp)."""
76
+ moment = time.time() if at is None else at
77
+ return _counter_code(secret, int(moment // TIME_STEP_SECONDS))
78
+
79
+
80
+ def verify_step(
81
+ secret: str,
82
+ code: str,
83
+ *,
84
+ at: float | None = None,
85
+ drift_steps: int = DEFAULT_DRIFT_STEPS,
86
+ ) -> int | None:
87
+ """Which time step *code* satisfies for *secret*, or ``None`` if none does.
88
+
89
+ The step is returned rather than a bare yes, because a caller cannot enforce
90
+ single use without knowing which code was spent: a code is valid for its whole
91
+ window, so "it verified" is true again thirty seconds later for the same six
92
+ digits. RFC 6238 section 5.2 puts the duty on the verifier, and the verifier is the
93
+ only party holding the state to discharge it.
94
+
95
+ Every candidate is compared in constant time **and** every candidate is compared:
96
+ returning early on the first match would leak, through timing, which step matched,
97
+ and with it the direction and size of the caller's clock error. The match is
98
+ recorded and the loop runs to the end.
99
+ """
100
+ candidate = code.strip().replace(" ", "")
101
+ if len(candidate) != DIGITS or not candidate.isdigit():
102
+ return None
103
+ moment = time.time() if at is None else at
104
+ step = int(moment // TIME_STEP_SECONDS)
105
+ matched: int | None = None
106
+ for offset in range(-drift_steps, drift_steps + 1):
107
+ expected = _counter_code(secret, step + offset)
108
+ hit = hmac.compare_digest(expected, candidate)
109
+ matched = step + offset if hit else matched
110
+ return matched
111
+
112
+
113
+ def verify(
114
+ secret: str,
115
+ code: str,
116
+ *,
117
+ at: float | None = None,
118
+ drift_steps: int = DEFAULT_DRIFT_STEPS,
119
+ ) -> bool:
120
+ """Whether *code* is valid for *secret* now, within the drift window.
121
+
122
+ Answers the question without the step, for callers that hold no state to spend it
123
+ against. A caller that stores an enrolment wants :func:`verify_step`.
124
+ """
125
+ return verify_step(secret, code, at=at, drift_steps=drift_steps) is not None
126
+
127
+
128
+ def provisioning_uri(secret: str, *, account: str, issuer: str) -> str:
129
+ """The ``otpauth://`` URI an authenticator app scans as a QR code.
130
+
131
+ Built here rather than by a caller because the parameter names are part of the
132
+ de-facto standard every app implements, and a typo in one produces an enrolment that
133
+ scans cleanly and then never matches.
134
+ """
135
+ from urllib.parse import quote, urlencode
136
+
137
+ label = quote(f"{issuer}:{account}", safe="")
138
+ query = urlencode(
139
+ {
140
+ "secret": secret,
141
+ "issuer": issuer,
142
+ "algorithm": "SHA1",
143
+ "digits": DIGITS,
144
+ "period": TIME_STEP_SECONDS,
145
+ }
146
+ )
147
+ return f"otpauth://totp/{label}?{query}"
148
+
149
+
150
+ __all__ = [
151
+ "DEFAULT_DRIFT_STEPS",
152
+ "DIGITS",
153
+ "TIME_STEP_SECONDS",
154
+ "generate",
155
+ "generate_secret",
156
+ "provisioning_uri",
157
+ "verify",
158
+ "verify_step",
159
+ ]