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.
- terp_cap_mfa-0.27.0/.gitignore +73 -0
- terp_cap_mfa-0.27.0/PKG-INFO +10 -0
- terp_cap_mfa-0.27.0/escape-hatch-budget.json +3 -0
- terp_cap_mfa-0.27.0/pyproject.toml +41 -0
- terp_cap_mfa-0.27.0/src/terp/capabilities/mfa/__init__.py +105 -0
- terp_cap_mfa-0.27.0/src/terp/capabilities/mfa/migrations/versions/a3f9c1e7b204_create_mfa_tables.py +75 -0
- terp_cap_mfa-0.27.0/src/terp/capabilities/mfa/models.py +86 -0
- terp_cap_mfa-0.27.0/src/terp/capabilities/mfa/operations.py +41 -0
- terp_cap_mfa-0.27.0/src/terp/capabilities/mfa/py.typed +0 -0
- terp_cap_mfa-0.27.0/src/terp/capabilities/mfa/recovery.py +88 -0
- terp_cap_mfa-0.27.0/src/terp/capabilities/mfa/router.py +194 -0
- terp_cap_mfa-0.27.0/src/terp/capabilities/mfa/schemas.py +88 -0
- terp_cap_mfa-0.27.0/src/terp/capabilities/mfa/sealing.py +91 -0
- terp_cap_mfa-0.27.0/src/terp/capabilities/mfa/service.py +247 -0
- terp_cap_mfa-0.27.0/src/terp/capabilities/mfa/totp.py +159 -0
|
@@ -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,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
|
+
]
|
terp_cap_mfa-0.27.0/src/terp/capabilities/mfa/migrations/versions/a3f9c1e7b204_create_mfa_tables.py
ADDED
|
@@ -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
|
+
]
|