File size: 7,997 Bytes
eea689d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
"""Per-user password authentication for the write endpoints (#128).

Replaces the single shared bearer token with real accounts. A reviewer logs in with a username and a
password; the server verifies it against a scrypt hash in the `users` table and issues a signed session
token. Every write then carries that token, and the server reads the confirmer and their role from the
authenticated account rather than from a self-declared field in the request body, so PRD section 4.4's
"who confirmed each value, and in what role" is non-repudiable rather than an honest guess.

Standard library only for the crypto (`hashlib.scrypt` for hashing, `hmac` for the token signature): the
serving image carries no auth dependency, and password hashing plus stateless token signing this
self-contained need none. `Identity` is the account an authenticated request resolves to.

Auth is enabled exactly when a session secret is configured (`ENDOPATH_SESSION_SECRET`), mirroring how the
old shared token gated the same endpoints. With no secret the gate is open, which keeps local dev and the
test suite free of ceremony; a public Space that sets no secret refuses to boot (see api.lifespan), so the
open default is never exposed to the internet.
"""

from __future__ import annotations

import base64
import hashlib
import hmac
import json
import os
import time
from typing import Optional

from pydantic import BaseModel, ConfigDict

SESSION_SECRET_ENV = "ENDOPATH_SESSION_SECRET"
# A JSON list of {username, password, role, holds_licence, display_name}, seeded into the users table at
# startup so a fresh deployment has accounts to log in with. Set as a Space Secret by deploy_space.py.
SEED_USERS_ENV = "ENDOPATH_SEED_USERS"

# A session lasts a working day, then the reviewer logs in again. Short enough that a leaked token expires,
# long enough not to interrupt a review sitting.
SESSION_TTL_SECONDS = 12 * 60 * 60

# scrypt work factors. n=2**15 keeps a single verify in the tens of milliseconds; maxmem is set explicitly
# (128 * n * r * p bytes, with headroom) so the call never trips OpenSSL's default memory cap.
_SCRYPT_N = 2**15
_SCRYPT_R = 8
_SCRYPT_P = 1
_SCRYPT_MAXMEM = 128 * _SCRYPT_N * _SCRYPT_R * _SCRYPT_P * 2
_SCRYPT_DKLEN = 32


class Identity(BaseModel):
    """The account an authenticated request resolves to. `role` and `holds_licence` are attributes of the
    account, set when it is provisioned, not values the client sends: that is what makes a confirmation's
    recorded role trustworthy (PRD section 4.4)."""

    model_config = ConfigDict(frozen=True)

    username: str
    role: str
    holds_licence: bool = False
    display_name: str = ""

    @property
    def confirmer(self) -> str:
        """The name recorded on a confirmation: the human display name where set, else the username."""
        return self.display_name.strip() or self.username


def session_secret() -> str:
    """The HMAC key the session tokens are signed with, read at call time so a Space Secret injected after
    import still takes effect (and a test can set or clear it). Empty when auth is disabled."""
    return os.environ.get(SESSION_SECRET_ENV, "").strip()


def auth_enabled() -> bool:
    """Whether write endpoints demand a logged-in account. True exactly when a session secret is set."""
    return bool(session_secret())


# --- password hashing ------------------------------------------------------


def hash_password(password: str) -> str:
    """A self-describing scrypt hash, `scrypt$n$r$p$salt_hex$hash_hex`, so `verify_password` can read back
    the parameters a hash was produced under and a later work-factor change does not strand old hashes."""
    salt = os.urandom(16)
    derived = hashlib.scrypt(
        password.encode("utf-8"),
        salt=salt,
        n=_SCRYPT_N,
        r=_SCRYPT_R,
        p=_SCRYPT_P,
        maxmem=_SCRYPT_MAXMEM,
        dklen=_SCRYPT_DKLEN,
    )
    return f"scrypt${_SCRYPT_N}${_SCRYPT_R}${_SCRYPT_P}${salt.hex()}${derived.hex()}"


def verify_password(password: str, stored: str) -> bool:
    """Constant-time check of a password against a stored `hash_password` string. False on any malformed
    stored value rather than raising, so a corrupt row denies access instead of crashing the login."""
    parts = stored.split("$")
    if len(parts) != 6 or parts[0] != "scrypt":
        return False
    _, n_s, r_s, p_s, salt_hex, hash_hex = parts
    try:
        expected = bytes.fromhex(hash_hex)
        derived = hashlib.scrypt(
            password.encode("utf-8"),
            salt=bytes.fromhex(salt_hex),
            n=int(n_s),
            r=int(r_s),
            p=int(p_s),
            maxmem=128 * int(n_s) * int(r_s) * int(p_s) * 2,
            dklen=len(expected),
        )
    except (ValueError, MemoryError):
        return False
    return hmac.compare_digest(derived, expected)


# --- stateless signed session tokens ---------------------------------------


def _b64e(raw: bytes) -> str:
    return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")


def _b64d(encoded: str) -> bytes:
    return base64.urlsafe_b64decode(encoded + "=" * (-len(encoded) % 4))


def _sign(body: str, secret: str) -> str:
    return _b64e(hmac.new(secret.encode("utf-8"), body.encode("ascii"), hashlib.sha256).digest())


def issue_token(
    identity: Identity, secret: str, *, now: Optional[int] = None, ttl: int = SESSION_TTL_SECONDS
) -> str:
    """A `<payload>.<signature>` token carrying the account and an expiry, signed with the session secret.
    Stateless: no server-side session store, so any process holding the secret can verify it."""
    issued = int(now if now is not None else time.time())
    payload = {
        "u": identity.username,
        "r": identity.role,
        "l": bool(identity.holds_licence),
        "n": identity.display_name,
        "exp": issued + ttl,
    }
    body = _b64e(json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8"))
    return f"{body}.{_sign(body, secret)}"


def decode_token(token: str, secret: str, *, now: Optional[int] = None) -> Optional[Identity]:
    """The `Identity` a token resolves to, or None when it is missing, malformed, wrongly signed, or
    expired. The signature is checked before the payload is trusted, in constant time."""
    if not token or "." not in token:
        return None
    body, _, signature = token.partition(".")
    if not hmac.compare_digest(signature, _sign(body, secret)):
        return None
    try:
        payload = json.loads(_b64d(body))
    except (ValueError, json.JSONDecodeError):
        return None
    current = int(now if now is not None else time.time())
    if not isinstance(payload, dict) or current >= int(payload.get("exp", 0)):
        return None
    try:
        return Identity(
            username=payload["u"],
            role=payload["r"],
            holds_licence=bool(payload["l"]),
            display_name=payload.get("n", ""),
        )
    except (KeyError, TypeError):
        return None


# --- account provisioning input --------------------------------------------


class SeedUser(BaseModel):
    """One account to provision from the seed environment: the credentials and the account-bound role and
    licence. `password` is the plaintext to hash once, never stored."""

    username: str
    password: str
    role: str
    holds_licence: bool = False
    display_name: str = ""


def parse_seed_users(raw: str) -> list[SeedUser]:
    """The accounts named in `ENDOPATH_SEED_USERS`, a JSON list. Empty when unset. Raises ValueError on a
    payload that is present but not a JSON list of the expected shape, so a misconfigured secret fails
    loudly at startup rather than silently seeding nothing."""
    if not raw or not raw.strip():
        return []
    data = json.loads(raw)
    if not isinstance(data, list):
        raise ValueError(f"{SEED_USERS_ENV} must be a JSON list, got {type(data).__name__}")
    return [SeedUser(**item) for item in data]