ssl-provisioning 1.0.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,45 @@
1
+ name: Publish to PyPI
2
+
3
+ # Publishes sslpv to PyPI when a version tag (vX.Y.Z) is pushed.
4
+ # Uses PyPI Trusted Publishing (OIDC) — no API token is stored.
5
+ # One-time setup on PyPI: add this repository and the workflow file name
6
+ # "pypi.yaml" as a trusted publisher for the "sslpv" project.
7
+
8
+ on:
9
+ push:
10
+ tags:
11
+ - "v*"
12
+
13
+ jobs:
14
+ publish:
15
+ runs-on: ubuntu-latest
16
+ permissions:
17
+ # Required for Trusted Publishing (OIDC token exchange with PyPI).
18
+ id-token: write
19
+ steps:
20
+ - name: Checkout
21
+ uses: actions/checkout@v4
22
+
23
+ - name: Install uv
24
+ uses: astral-sh/setup-uv@v6
25
+ with:
26
+ python-version: "3.10"
27
+
28
+ - name: Verify tag matches pyproject version
29
+ run: |
30
+ tag_version="${GITHUB_REF_NAME#v}"
31
+ pkg_version="$(grep -E '^version *= *' pyproject.toml | head -1 | sed -E 's/.*"([^"]+)".*/\1/')"
32
+ echo "tag=${tag_version} pyproject=${pkg_version}"
33
+ if [ "${tag_version}" != "${pkg_version}" ]; then
34
+ echo "::error::Tag ${tag_version} does not match pyproject.toml version ${pkg_version}"
35
+ exit 1
36
+ fi
37
+
38
+ - name: Run tests
39
+ run: uv run pytest -q
40
+
41
+ - name: Build distributions
42
+ run: uv build
43
+
44
+ - name: Publish to PyPI
45
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,12 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .pytest_cache/
5
+ .venv/
6
+ dist/
7
+ build/
8
+ *.pem
9
+ *.crt
10
+ *.key
11
+ config.json
12
+ !config.example.json
@@ -0,0 +1,172 @@
1
+ Metadata-Version: 2.4
2
+ Name: ssl-provisioning
3
+ Version: 1.0.0
4
+ Summary: SSL certificate provisioning tool: a FastAPI server distributes fullchain/privkey to one-shot CLI clients over an authenticated, end-to-end encrypted channel.
5
+ License: MIT
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: cryptography>=42.0
8
+ Requires-Dist: fastapi>=0.110
9
+ Requires-Dist: prompt-toolkit>=3.0
10
+ Requires-Dist: uvicorn>=0.27
11
+ Description-Content-Type: text/markdown
12
+
13
+ # sslpv
14
+
15
+ `sslpv` is an SSL certificate provisioning tool. A long-running FastAPI server holds
16
+ the paths to a `fullchain`/`privkey` pair and a set of API keys. A one-shot CLI client
17
+ authenticates with an API key and pulls the current certificate and private key over an
18
+ authenticated, end-to-end encrypted channel, writing them atomically to local paths.
19
+
20
+ ---
21
+
22
+ ## Installation
23
+
24
+ The distribution is published on PyPI as `ssl-provisioning`; the installed command
25
+ and import package are both named `sslpv`.
26
+
27
+ **From PyPI:**
28
+
29
+ ```sh
30
+ pip install ssl-provisioning
31
+ # or, one-shot without a permanent install:
32
+ uvx --from ssl-provisioning sslpv --help
33
+ ```
34
+
35
+ **From a local checkout:**
36
+
37
+ ```sh
38
+ uv pip install . # or: pip install .
39
+ ```
40
+
41
+ ---
42
+
43
+ ## Server
44
+
45
+ ### Starting the server
46
+
47
+ ```sh
48
+ sslpv server --config /path/to/config.json
49
+ ```
50
+
51
+ The server blocks until interrupted (Ctrl-C).
52
+
53
+ ### config.json reference
54
+
55
+ ```json
56
+ {
57
+ "fullchain": "/etc/letsencrypt/live/example.com/fullchain.pem",
58
+ "privkey": "/etc/letsencrypt/live/example.com/privkey.pem",
59
+ "apikeys": ["replace-with-a-long-random-secret", "another-client-key"],
60
+ "host": "0.0.0.0",
61
+ "port": 1243,
62
+ "server_certfile": null,
63
+ "server_keyfile": null,
64
+ "trusted_proxies": []
65
+ }
66
+ ```
67
+
68
+ | Field | Type | Required | Description |
69
+ |---|---|---|---|
70
+ | `fullchain` | string | yes | Path to the PEM fullchain certificate to distribute to clients. |
71
+ | `privkey` | string | yes | Path to the PEM private key to distribute to clients. |
72
+ | `apikeys` | list of strings | yes | One or more API keys that clients may authenticate with. |
73
+ | `host` | string | no | Bind address. Defaults to `"0.0.0.0"`. |
74
+ | `port` | int | no | TCP port to listen on. Defaults to `1243`. |
75
+ | `server_certfile` | string or null | no | TLS certificate for the server itself. Falls back to `fullchain` when null. |
76
+ | `server_keyfile` | string or null | no | TLS private key for the server itself. Falls back to `privkey` when null. |
77
+ | `trusted_proxies` | list of strings | no | IP addresses of trusted reverse proxies whose `X-Forwarded-For` header is used to determine the real client IP for rate limiting. |
78
+
79
+ ### File permissions
80
+
81
+ The server hard-errors on startup if the config file is readable by group or other.
82
+ Restrict permissions before starting:
83
+
84
+ ```sh
85
+ chmod 600 /path/to/config.json
86
+ chmod 600 /path/to/privkey.pem
87
+ ```
88
+
89
+ ---
90
+
91
+ ## Client
92
+
93
+ ```sh
94
+ sslpv client \
95
+ --server https://example.com:1243 \
96
+ --key /path/to/apikey.txt \
97
+ --cert /path/to/fullchain.pem \
98
+ --privkey /path/to/privkey.pem
99
+ ```
100
+
101
+ ### Client flags
102
+
103
+ | Flag | Required | Description |
104
+ |---|---|---|
105
+ | `--server URL` | yes | Server base URL. Must use `https`. |
106
+ | `--key PATH` | yes | File containing the API key. |
107
+ | `--cert PATH` | yes | Destination path for the retrieved PEM certificate. |
108
+ | `--privkey PATH` | yes | Destination path for the retrieved PEM private key. |
109
+ | `--insecure` | no | Disable TLS certificate verification. Dangerous; see note below. |
110
+ | `--ca-cert PATH` | no | Path to a custom PEM CA bundle for TLS verification. |
111
+ | `--pin-sha256 HEX` | no | Expected SHA-256 hex fingerprint of the server leaf certificate. |
112
+ | `--timeout SECONDS` | no | Per-request timeout in seconds. Default: `30.0`. |
113
+
114
+ ### TLS for self-signed or IP-addressed servers
115
+
116
+ For servers with self-signed certificates or no matching DNS name, use `--ca-cert` or
117
+ `--pin-sha256` instead of `--insecure`:
118
+
119
+ ```sh
120
+ # Trust a custom CA bundle
121
+ sslpv client --server https://192.0.2.1:1243 --ca-cert /path/to/ca.pem ...
122
+
123
+ # Pin by SHA-256 fingerprint (colons optional, case-insensitive)
124
+ sslpv client --server https://192.0.2.1:1243 --pin-sha256 ab:cd:ef:... ...
125
+ ```
126
+
127
+ `--insecure` disables all chain and hostname verification and leaves the connection
128
+ vulnerable to MITM attacks. The end-to-end encryption described below still protects
129
+ the payload content, but the identity of the server is not verified.
130
+
131
+ ---
132
+
133
+ ## How it works
134
+
135
+ ### Stateless signed challenge-response authentication
136
+
137
+ 1. The client fetches a signed one-time nonce from `/challenge`.
138
+ 2. The client computes an HMAC proof that binds the API key, HTTP method, endpoint path,
139
+ nonce, issue timestamp, and an ephemeral X25519 public key. The raw API key is never
140
+ sent over the wire.
141
+ 3. The server verifies the proof, checks the nonce has not been spent, and marks the
142
+ nonce as spent immediately (one-time use).
143
+
144
+ ### End-to-end AES-256-GCM payload encryption
145
+
146
+ Each response payload (certificate or private key) is encrypted with AES-256-GCM. The
147
+ symmetric key is derived from an X25519 ECDH exchange between a server-side ephemeral
148
+ key and the client's ephemeral key, with the API key mixed in as additional key
149
+ material. Even if the TLS layer is broken or bypassed (e.g. by an on-path attacker
150
+ under `--insecure`), the payload cannot be decrypted or forged without knowledge of the
151
+ API key.
152
+
153
+ ### TLS transport
154
+
155
+ The server uses uvicorn with `ssl.PROTOCOL_TLS_SERVER` (TLS 1.2 or higher) and a
156
+ modern cipher suite (`ECDHE+AESGCM:ECDHE+CHACHA20`). Use `--ca-cert` or `--pin-sha256`
157
+ on the client when the server certificate is not trusted by the system CA store.
158
+
159
+ ---
160
+
161
+ ## Security model and limitations
162
+
163
+ - **Availability / DoS**: Protection against on-path denial-of-service is out of scope.
164
+ Rate limiting is implemented per-IP but an on-path attacker can still disrupt
165
+ availability.
166
+ - **Single-process requirement**: The server must run with `workers=1` (the default).
167
+ Nonce deduplication and the rate limiter use in-memory state; multiple workers would
168
+ allow nonce replay across process boundaries.
169
+ - **API key confidentiality**: Keep API key files restricted to `0600`. A key with group
170
+ or other read permission will trigger a warning from the client.
171
+ - **Config file confidentiality**: The server rejects a config file with group or other
172
+ read bits set. Always `chmod 600` the config.
@@ -0,0 +1,160 @@
1
+ # sslpv
2
+
3
+ `sslpv` is an SSL certificate provisioning tool. A long-running FastAPI server holds
4
+ the paths to a `fullchain`/`privkey` pair and a set of API keys. A one-shot CLI client
5
+ authenticates with an API key and pulls the current certificate and private key over an
6
+ authenticated, end-to-end encrypted channel, writing them atomically to local paths.
7
+
8
+ ---
9
+
10
+ ## Installation
11
+
12
+ The distribution is published on PyPI as `ssl-provisioning`; the installed command
13
+ and import package are both named `sslpv`.
14
+
15
+ **From PyPI:**
16
+
17
+ ```sh
18
+ pip install ssl-provisioning
19
+ # or, one-shot without a permanent install:
20
+ uvx --from ssl-provisioning sslpv --help
21
+ ```
22
+
23
+ **From a local checkout:**
24
+
25
+ ```sh
26
+ uv pip install . # or: pip install .
27
+ ```
28
+
29
+ ---
30
+
31
+ ## Server
32
+
33
+ ### Starting the server
34
+
35
+ ```sh
36
+ sslpv server --config /path/to/config.json
37
+ ```
38
+
39
+ The server blocks until interrupted (Ctrl-C).
40
+
41
+ ### config.json reference
42
+
43
+ ```json
44
+ {
45
+ "fullchain": "/etc/letsencrypt/live/example.com/fullchain.pem",
46
+ "privkey": "/etc/letsencrypt/live/example.com/privkey.pem",
47
+ "apikeys": ["replace-with-a-long-random-secret", "another-client-key"],
48
+ "host": "0.0.0.0",
49
+ "port": 1243,
50
+ "server_certfile": null,
51
+ "server_keyfile": null,
52
+ "trusted_proxies": []
53
+ }
54
+ ```
55
+
56
+ | Field | Type | Required | Description |
57
+ |---|---|---|---|
58
+ | `fullchain` | string | yes | Path to the PEM fullchain certificate to distribute to clients. |
59
+ | `privkey` | string | yes | Path to the PEM private key to distribute to clients. |
60
+ | `apikeys` | list of strings | yes | One or more API keys that clients may authenticate with. |
61
+ | `host` | string | no | Bind address. Defaults to `"0.0.0.0"`. |
62
+ | `port` | int | no | TCP port to listen on. Defaults to `1243`. |
63
+ | `server_certfile` | string or null | no | TLS certificate for the server itself. Falls back to `fullchain` when null. |
64
+ | `server_keyfile` | string or null | no | TLS private key for the server itself. Falls back to `privkey` when null. |
65
+ | `trusted_proxies` | list of strings | no | IP addresses of trusted reverse proxies whose `X-Forwarded-For` header is used to determine the real client IP for rate limiting. |
66
+
67
+ ### File permissions
68
+
69
+ The server hard-errors on startup if the config file is readable by group or other.
70
+ Restrict permissions before starting:
71
+
72
+ ```sh
73
+ chmod 600 /path/to/config.json
74
+ chmod 600 /path/to/privkey.pem
75
+ ```
76
+
77
+ ---
78
+
79
+ ## Client
80
+
81
+ ```sh
82
+ sslpv client \
83
+ --server https://example.com:1243 \
84
+ --key /path/to/apikey.txt \
85
+ --cert /path/to/fullchain.pem \
86
+ --privkey /path/to/privkey.pem
87
+ ```
88
+
89
+ ### Client flags
90
+
91
+ | Flag | Required | Description |
92
+ |---|---|---|
93
+ | `--server URL` | yes | Server base URL. Must use `https`. |
94
+ | `--key PATH` | yes | File containing the API key. |
95
+ | `--cert PATH` | yes | Destination path for the retrieved PEM certificate. |
96
+ | `--privkey PATH` | yes | Destination path for the retrieved PEM private key. |
97
+ | `--insecure` | no | Disable TLS certificate verification. Dangerous; see note below. |
98
+ | `--ca-cert PATH` | no | Path to a custom PEM CA bundle for TLS verification. |
99
+ | `--pin-sha256 HEX` | no | Expected SHA-256 hex fingerprint of the server leaf certificate. |
100
+ | `--timeout SECONDS` | no | Per-request timeout in seconds. Default: `30.0`. |
101
+
102
+ ### TLS for self-signed or IP-addressed servers
103
+
104
+ For servers with self-signed certificates or no matching DNS name, use `--ca-cert` or
105
+ `--pin-sha256` instead of `--insecure`:
106
+
107
+ ```sh
108
+ # Trust a custom CA bundle
109
+ sslpv client --server https://192.0.2.1:1243 --ca-cert /path/to/ca.pem ...
110
+
111
+ # Pin by SHA-256 fingerprint (colons optional, case-insensitive)
112
+ sslpv client --server https://192.0.2.1:1243 --pin-sha256 ab:cd:ef:... ...
113
+ ```
114
+
115
+ `--insecure` disables all chain and hostname verification and leaves the connection
116
+ vulnerable to MITM attacks. The end-to-end encryption described below still protects
117
+ the payload content, but the identity of the server is not verified.
118
+
119
+ ---
120
+
121
+ ## How it works
122
+
123
+ ### Stateless signed challenge-response authentication
124
+
125
+ 1. The client fetches a signed one-time nonce from `/challenge`.
126
+ 2. The client computes an HMAC proof that binds the API key, HTTP method, endpoint path,
127
+ nonce, issue timestamp, and an ephemeral X25519 public key. The raw API key is never
128
+ sent over the wire.
129
+ 3. The server verifies the proof, checks the nonce has not been spent, and marks the
130
+ nonce as spent immediately (one-time use).
131
+
132
+ ### End-to-end AES-256-GCM payload encryption
133
+
134
+ Each response payload (certificate or private key) is encrypted with AES-256-GCM. The
135
+ symmetric key is derived from an X25519 ECDH exchange between a server-side ephemeral
136
+ key and the client's ephemeral key, with the API key mixed in as additional key
137
+ material. Even if the TLS layer is broken or bypassed (e.g. by an on-path attacker
138
+ under `--insecure`), the payload cannot be decrypted or forged without knowledge of the
139
+ API key.
140
+
141
+ ### TLS transport
142
+
143
+ The server uses uvicorn with `ssl.PROTOCOL_TLS_SERVER` (TLS 1.2 or higher) and a
144
+ modern cipher suite (`ECDHE+AESGCM:ECDHE+CHACHA20`). Use `--ca-cert` or `--pin-sha256`
145
+ on the client when the server certificate is not trusted by the system CA store.
146
+
147
+ ---
148
+
149
+ ## Security model and limitations
150
+
151
+ - **Availability / DoS**: Protection against on-path denial-of-service is out of scope.
152
+ Rate limiting is implemented per-IP but an on-path attacker can still disrupt
153
+ availability.
154
+ - **Single-process requirement**: The server must run with `workers=1` (the default).
155
+ Nonce deduplication and the rate limiter use in-memory state; multiple workers would
156
+ allow nonce replay across process boundaries.
157
+ - **API key confidentiality**: Keep API key files restricted to `0600`. A key with group
158
+ or other read permission will trigger a warning from the client.
159
+ - **Config file confidentiality**: The server rejects a config file with group or other
160
+ read bits set. Always `chmod 600` the config.
@@ -0,0 +1,10 @@
1
+ {
2
+ "fullchain": "/etc/letsencrypt/live/example.com/fullchain.pem",
3
+ "privkey": "/etc/letsencrypt/live/example.com/privkey.pem",
4
+ "apikeys": ["replace-with-a-long-random-secret", "another-client-key"],
5
+ "host": "0.0.0.0",
6
+ "port": 1243,
7
+ "server_certfile": null,
8
+ "server_keyfile": null,
9
+ "trusted_proxies": []
10
+ }
@@ -0,0 +1,32 @@
1
+ [project]
2
+ name = "ssl-provisioning"
3
+ version = "1.0.0"
4
+ description = "SSL certificate provisioning tool: a FastAPI server distributes fullchain/privkey to one-shot CLI clients over an authenticated, end-to-end encrypted channel."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = { text = "MIT" }
8
+ dependencies = [
9
+ "fastapi>=0.110",
10
+ "uvicorn>=0.27",
11
+ "prompt_toolkit>=3.0",
12
+ "cryptography>=42.0",
13
+ ]
14
+
15
+ [project.scripts]
16
+ sslpv = "sslpv.main:main"
17
+
18
+ [dependency-groups]
19
+ dev = [
20
+ "pytest>=8.0",
21
+ "httpx>=0.27",
22
+ ]
23
+
24
+ [build-system]
25
+ requires = ["hatchling"]
26
+ build-backend = "hatchling.build"
27
+
28
+ [tool.hatch.build.targets.wheel]
29
+ packages = ["src/sslpv"]
30
+
31
+ [tool.pytest.ini_options]
32
+ testpaths = ["tests"]
@@ -0,0 +1,3 @@
1
+ """sslpv: SSL certificate provisioning server and client."""
2
+
3
+ __version__ = "1.0.0"
@@ -0,0 +1,176 @@
1
+ """FastAPI dependencies: authentication and rate limiting."""
2
+
3
+ import base64
4
+ import time
5
+ from typing import Any
6
+
7
+ from cryptography.hazmat.primitives.asymmetric import x25519
8
+ from fastapi import Depends, HTTPException, Request
9
+
10
+ from sslpv.utils.crypto import verify_challenge, verify_proof
11
+
12
+ # Challenge TTL in seconds
13
+ TTL = 60
14
+
15
+ # Maximum allowed clock skew between client and server
16
+ _CLOCK_SKEW = 5
17
+
18
+
19
+ def _parse_auth_header(authorization: str) -> tuple[str, int, str, str]:
20
+ """Parse a Bearer token of the form ``Bearer v1.<nonce>.<ts>.<sig>.<proof>``.
21
+
22
+ Args:
23
+ authorization(str): Raw Authorization header value.
24
+
25
+ Return:
26
+ parts(tuple): ``(nonce_b64, issue_ts, sig_b64, proof_b64)``.
27
+
28
+ Raises:
29
+ HTTPException: 401 if the header is missing, malformed, or wrong version.
30
+ """
31
+ if not authorization or not authorization.startswith("Bearer "):
32
+ raise HTTPException(401, "unauthorized")
33
+
34
+ token = authorization[len("Bearer "):]
35
+
36
+ if not token.startswith("v1."):
37
+ raise HTTPException(401, "unauthorized")
38
+
39
+ body = token[len("v1."):]
40
+ # nonce_b64, issue_ts, sig_b64, proof_b64 — base64 segments never contain '.'
41
+ parts = body.split(".")
42
+ if len(parts) != 4:
43
+ raise HTTPException(401, "unauthorized")
44
+
45
+ nonce_b64, ts_str, sig_b64, proof_b64 = parts
46
+
47
+ try:
48
+ issue_ts = int(ts_str)
49
+ except ValueError:
50
+ raise HTTPException(401, "unauthorized")
51
+
52
+ return nonce_b64, issue_ts, sig_b64, proof_b64
53
+
54
+
55
+ def _parse_client_pubkey(header_value: str) -> tuple[bytes, str]:
56
+ """Decode and validate the X-Client-Pubkey header.
57
+
58
+ Args:
59
+ header_value(str): Base64-encoded 32-byte X25519 public key.
60
+
61
+ Return:
62
+ result(tuple): ``(raw_bytes, b64_string)``.
63
+
64
+ Raises:
65
+ HTTPException: 400 if the header is missing, not valid base64, or not 32 bytes.
66
+ """
67
+ try:
68
+ raw = base64.b64decode(header_value, validate=True)
69
+ except Exception:
70
+ raise HTTPException(400, "invalid X-Client-Pubkey header")
71
+
72
+ if len(raw) != 32:
73
+ raise HTTPException(400, "invalid X-Client-Pubkey header")
74
+
75
+ try:
76
+ x25519.X25519PublicKey.from_public_bytes(raw)
77
+ except Exception:
78
+ raise HTTPException(400, "invalid X-Client-Pubkey header")
79
+
80
+ return raw, header_value
81
+
82
+
83
+ def _get_client_ip(request: Request, trusted_proxies: list[str]) -> str:
84
+ """Determine the real client IP, honouring trusted proxy forwarding.
85
+
86
+ Args:
87
+ request(Request): The incoming FastAPI request.
88
+ trusted_proxies(list[str]): IP addresses of trusted reverse proxies.
89
+
90
+ Return:
91
+ ip(str): The effective client IP address.
92
+ """
93
+ direct_ip = request.client.host if request.client else "unknown"
94
+ if direct_ip in trusted_proxies:
95
+ forwarded = request.headers.get("X-Forwarded-For", "")
96
+ first = forwarded.split(",")[0].strip()
97
+ if first:
98
+ return first
99
+ return direct_ip
100
+
101
+
102
+ async def verify_auth(request: Request) -> dict[str, Any]:
103
+ """FastAPI dependency that authenticates and rate-limits every protected request.
104
+
105
+ Authentication steps (order is security-critical):
106
+ 1. Determine real client IP; apply rate limiting.
107
+ 2. Parse Authorization header (Bearer v1 token) and X-Client-Pubkey header.
108
+ 3. Verify challenge signature.
109
+ 4. Verify token freshness (TTL and clock skew).
110
+ 5. Check nonce has not been spent.
111
+ 6. Verify API-key proof (bound to method, path, nonce, pubkey).
112
+ 7. Mark nonce as spent; return auth context.
113
+
114
+ All 401 failures return the same message ("unauthorized") to avoid oracles.
115
+
116
+ Args:
117
+ request(Request): The incoming FastAPI request.
118
+
119
+ Return:
120
+ auth(dict): ``{"apikey": str, "client_pubkey": bytes}``.
121
+
122
+ Raises:
123
+ HTTPException: 401 for auth failures, 429 for rate limiting, 400 for bad headers.
124
+ """
125
+ config = request.app.state.config
126
+ server_secret: bytes = request.app.state.server_secret
127
+ spent_nonces = request.app.state.spent_nonces
128
+ rate_limiter = request.app.state.rate_limiter
129
+
130
+ # Step a: rate limiting
131
+ ip = _get_client_ip(request, config.trusted_proxies)
132
+ if not rate_limiter.allow(ip):
133
+ raise HTTPException(429, "rate limited")
134
+
135
+ # Step b: parse Authorization header
136
+ authorization = request.headers.get("Authorization", "")
137
+ nonce_b64, issue_ts, sig_b64, proof_b64 = _parse_auth_header(authorization)
138
+
139
+ client_pubkey_header = request.headers.get("X-Client-Pubkey", "")
140
+ if not client_pubkey_header:
141
+ raise HTTPException(400, "invalid X-Client-Pubkey header")
142
+ client_pubkey_raw, client_pubkey_b64 = _parse_client_pubkey(client_pubkey_header)
143
+
144
+ # Step c: verify challenge signature
145
+ if not verify_challenge(server_secret, nonce_b64, issue_ts, sig_b64):
146
+ raise HTTPException(401, "unauthorized")
147
+
148
+ # Step d: check expiry and clock skew
149
+ now = int(time.time())
150
+ if now - issue_ts > TTL:
151
+ raise HTTPException(401, "unauthorized")
152
+ if issue_ts > now + _CLOCK_SKEW:
153
+ raise HTTPException(401, "unauthorized")
154
+
155
+ # Step e: check spent nonce
156
+ if spent_nonces.contains(nonce_b64):
157
+ raise HTTPException(401, "unauthorized")
158
+
159
+ # Step f: verify proof (bound to method, path, nonce, pubkey, apikey)
160
+ matched_apikey = verify_proof(
161
+ proof_b64,
162
+ config.apikeys,
163
+ request.method,
164
+ request.url.path,
165
+ nonce_b64,
166
+ issue_ts,
167
+ client_pubkey_b64,
168
+ )
169
+ if matched_apikey is None:
170
+ # Do NOT mark the nonce as spent — the attacker has not authenticated
171
+ raise HTTPException(401, "unauthorized")
172
+
173
+ # Step g: mark nonce spent and return auth context
174
+ spent_nonces.add(nonce_b64, expiry=issue_ts + TTL)
175
+
176
+ return {"apikey": matched_apikey, "client_pubkey": client_pubkey_raw}