Skip to content

Commit 1f4c50b

Browse files
fix(docs): document secure paseto key management
1 parent b95ffa9 commit 1f4c50b

12 files changed

Lines changed: 572 additions & 48 deletions

File tree

README.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,15 @@ like this:
5454
```python
5555
@AuthPASETO.load_config
5656
def get_config():
57-
return {"authpaseto_secret_key": "secret"}
57+
return {"authpaseto_secret_key": "secret"} # Demo only.
5858
```
5959

60+
For production, do not hardcode `authpaseto_secret_key` or private signing
61+
keys in source code. Generate high-entropy key material and retrieve it from a
62+
secure store in `load_config()`. See the `Key Management` page in the
63+
documentation for symmetric secrets, public/private PEM material, TPM-backed
64+
storage, Fernet-wrapped blobs, Vault, Keycloak, and file-loading tradeoffs.
65+
6066
## Release Process
6167
Releases are automated with conventional commits and semantic versioning.
6268
Commits merged into `main` should follow the Conventional Commits format, for
Lines changed: 363 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,363 @@
1+
# Key Management
2+
3+
This library is commonly used for header-based application authentication, which
4+
means the configured key material becomes part of your application's root of
5+
trust.
6+
7+
For `local` tokens, `authpaseto_secret_key` is the secret that protects both:
8+
9+
- confidentiality, because holders of the key can decrypt token contents
10+
- authenticity, because holders of the key can mint valid tokens
11+
12+
For `public` tokens, the trust model splits:
13+
14+
- `authpaseto_private_key` is the signing secret and must be protected like any
15+
other production credential
16+
- `authpaseto_public_key` is not secret, but it still must come from an
17+
authenticated source so validators do not accept a stale or attacker-swapped
18+
key
19+
20+
FastAPI PASETO currently accepts the `local` symmetric key as an in-memory
21+
string returned by `AuthPASETO.load_config()`. It also supports
22+
`authpaseto_private_key_file` and `authpaseto_public_key_file`, but file loading
23+
should be treated as a fallback for constrained deployments, not the preferred
24+
production design.
25+
26+
## Use `load_config()` as the retrieval boundary
27+
28+
The safest pattern is to fetch or unwrap the key material inside the
29+
configuration callback and only then return it to the library:
30+
31+
```python
32+
@AuthPASETO.load_config
33+
def get_config():
34+
return {
35+
"authpaseto_secret_key": fetch_secret_from_secure_store(),
36+
"authpaseto_private_key": fetch_private_key_from_secure_store(),
37+
"authpaseto_public_key": fetch_public_key_from_secure_store(),
38+
}
39+
```
40+
41+
That keeps the sensitive retrieval logic in one place and avoids teaching users
42+
to bake secrets into source code, images, or repo-tracked files.
43+
44+
## Generate strong key material
45+
46+
### Local purpose secret keys
47+
48+
Use a CSPRNG and generate at least 32 random bytes. Avoid human-chosen strings,
49+
shared team passwords, or values copied from examples.
50+
51+
Python:
52+
53+
```python
54+
import base64
55+
import secrets
56+
57+
raw_secret = secrets.token_bytes(32)
58+
encoded_secret = base64.urlsafe_b64encode(raw_secret).decode("ascii")
59+
```
60+
61+
OpenSSL:
62+
63+
```bash
64+
openssl rand -base64 32
65+
```
66+
67+
Store the exact value consistently. If your storage layer expects a text value,
68+
store the encoded value and return the same string from `load_config()`.
69+
70+
### Public purpose key pairs
71+
72+
Generate asymmetric keys with mature tooling and keep the private key out of the
73+
application repository.
74+
75+
Ed25519 PEM example:
76+
77+
```bash
78+
openssl genpkey -algorithm ED25519 -out private_key.pem
79+
openssl pkey -in private_key.pem -pubout -out public_key.pem
80+
```
81+
82+
The private key is secret signing material. The public key can be distributed
83+
more broadly, but validators should still retrieve it from an authenticated and
84+
rotation-aware source.
85+
86+
## Why file loading is a fallback
87+
88+
`authpaseto_private_key_file` and `authpaseto_public_key_file` are useful when a
89+
file mount is the only practical interface your platform provides, but they come
90+
with tradeoffs:
91+
92+
- files are easy to copy into backups, container layers, shell histories, and
93+
support bundles
94+
- permissions drift is common, especially on shared hosts and ephemeral
95+
instances
96+
- rotation usually means replacing files on disk and coordinating reload timing
97+
- local files do not provide audit trails, version history, or centralized
98+
access control by themselves
99+
100+
Use file loading only when it is the only viable integration point for the
101+
environment, or as a short-lived bridge while you move to a stronger secret
102+
distribution model. If you must use files, keep them outside the repository,
103+
limit filesystem permissions, avoid baking them into images, and document the
104+
rotation procedure.
105+
106+
## Storage and retrieval options
107+
108+
### TPM with `tpm2-pytss`
109+
110+
Best fit:
111+
112+
- single-host deployments
113+
- protecting `authpaseto_secret_key` or `authpaseto_private_key`
114+
- environments that can tolerate hardware coupling and operational complexity
115+
116+
Advantages:
117+
118+
- hardware-backed protection against simple filesystem theft
119+
- supports sealing key material to platform state or policy
120+
- keeps the highest-value secret or private key out of ordinary application
121+
storage
122+
123+
Disadvantages:
124+
125+
- host-bound design complicates autoscaling, migration, and disaster recovery
126+
- PCR-bound policies can break after firmware, kernel, or boot-chain changes
127+
- development, containers, and CI are harder because hardware is not always
128+
present
129+
130+
Example retrieval:
131+
132+
```python
133+
from pathlib import Path
134+
135+
from tpm2_pytss import ESAPI
136+
137+
138+
def load_sealed_secret() -> str:
139+
"""Unseal a previously stored TPM object and return it as text."""
140+
141+
sealed_blob = Path("/run/secrets/paseto-local.blob").read_bytes()
142+
with ESAPI() as esys:
143+
sealed_handle = esys.load_blob(sealed_blob)
144+
return bytes(esys.unseal(sealed_handle)).decode("utf-8")
145+
```
146+
147+
The exact handle-loading and authorization steps depend on how the sealed TPM
148+
object was provisioned in your environment.
149+
150+
The same pattern can return a PEM private key instead of a symmetric secret. A
151+
common deployment model is: store the private key or local secret sealed by the
152+
TPM, then publish the public key through a separate authenticated distribution
153+
path.
154+
155+
### Fernet-wrapped local storage
156+
157+
Best fit:
158+
159+
- single-host or low-complexity deployments
160+
- envelope-encrypting a local secret blob or PEM file before it is read by the
161+
app
162+
- incremental hardening when a real secret manager is not yet available
163+
164+
Advantages:
165+
166+
- straightforward Python integration
167+
- better than storing raw secrets or PEM files in plaintext
168+
- usable for both `authpaseto_secret_key` and `authpaseto_private_key`
169+
170+
Disadvantages:
171+
172+
- the Fernet key becomes another secret that still needs strong protection
173+
- if the Fernet key lives next to the ciphertext, security gains are minimal
174+
- does not provide centralized audit, policy, or rotation by itself
175+
176+
Example retrieval:
177+
178+
```python
179+
from pathlib import Path
180+
181+
from cryptography.fernet import Fernet
182+
183+
184+
def decrypt_secret_blob() -> str:
185+
"""Decrypt a local secret or PEM blob before passing it to the library."""
186+
187+
fernet_key = Path("/run/secrets/paseto-fernet.key").read_bytes()
188+
encrypted_blob = Path("/run/secrets/paseto.enc").read_bytes()
189+
return Fernet(fernet_key).decrypt(encrypted_blob).decode("utf-8")
190+
```
191+
192+
Use this only if the Fernet key itself comes from a stronger root of trust such
193+
as a TPM, OS secret store, or centralized secret manager.
194+
195+
### OS secret stores or protected mounted secrets
196+
197+
Best fit:
198+
199+
- single-host deployments
200+
- Kubernetes or platform secret injection
201+
- moderate-complexity environments where a central secret service is not
202+
available
203+
204+
Advantages:
205+
206+
- usually simpler to operate than TPM or Vault
207+
- works for `authpaseto_secret_key`, PEM private keys, and public-key
208+
distribution
209+
- often integrates well with existing deployment tooling
210+
211+
Disadvantages:
212+
213+
- still vulnerable to host compromise or accidental process-level disclosure
214+
- permissions and mount configuration matter a lot
215+
- audit and rotation support depend on the underlying platform
216+
217+
Use this when the platform injects secrets at runtime, not when the values are
218+
hardcoded into `.env` files committed to the repo.
219+
220+
### HashiCorp Vault
221+
222+
Best fit:
223+
224+
- multi-instance production deployments
225+
- teams that need centralized access control, versioning, auditing, and planned
226+
rotation
227+
- managing both local symmetric secrets and public/private key material
228+
229+
Advantages:
230+
231+
- strong centralized policy and audit model
232+
- secret versioning and controlled rotation workflows
233+
- clean separation between application code and long-lived key material
234+
235+
Disadvantages:
236+
237+
- another critical service to run, secure, and keep highly available
238+
- application bootstrap and auth to Vault must be designed carefully
239+
- operationally heavier than local-only approaches
240+
241+
Example retrieval:
242+
243+
```python
244+
import os
245+
246+
import hvac
247+
248+
249+
def read_from_vault() -> dict[str, str]:
250+
"""Fetch the PASETO key material from Vault KV storage."""
251+
252+
client = hvac.Client(url="https://vault.internal:8200")
253+
client.auth.approle.login(
254+
role_id=os.environ["VAULT_ROLE_ID"],
255+
secret_id=os.environ["VAULT_SECRET_ID"],
256+
)
257+
payload = client.secrets.kv.v2.read_secret_version(path="apps/fastapi-paseto")
258+
return payload["data"]["data"]
259+
```
260+
261+
```python
262+
@AuthPASETO.load_config
263+
def get_config():
264+
keys = read_from_vault()
265+
return {
266+
"authpaseto_secret_key": keys["local_secret_key"],
267+
"authpaseto_private_key": keys["private_key_pem"],
268+
"authpaseto_public_key": keys["public_key_pem"],
269+
}
270+
```
271+
272+
Vault is usually the best documented centralized choice in this space when you
273+
need multiple services or instances to share and rotate keys.
274+
275+
### Keycloak
276+
277+
Best fit:
278+
279+
- deployments that already standardize heavily on Keycloak for identity flows
280+
- cases where Keycloak participates in broader key-distribution decisions
281+
282+
Advantages:
283+
284+
- can align key management with an existing identity control plane
285+
- may reduce the number of separate platforms a team must learn
286+
287+
Disadvantages:
288+
289+
- not a dedicated general-purpose secret manager
290+
- less natural fit than Vault for arbitrary application secret storage and
291+
operational rotation workflows
292+
- can encourage coupling application secrets to an identity platform that was
293+
not chosen for this job
294+
295+
Keycloak can be part of the architecture, especially for public-key
296+
distribution, but it should usually not be the first recommendation for storing
297+
the `local` secret or private signing key. Prefer Vault, TPM, or an established
298+
platform secret store first.
299+
300+
### Environment variables
301+
302+
Best fit:
303+
304+
- local development
305+
- production only when injected by a real secret-management layer
306+
307+
Advantages:
308+
309+
- easy to wire into `load_config()`
310+
- no extra client library needed for the application
311+
312+
Disadvantages:
313+
314+
- values can leak through process inspection, crash dumps, debug tooling, and
315+
deployment misconfiguration
316+
- rotation typically requires process restarts
317+
- weak choice if the variables are manually copied into shell startup files or
318+
`.env` files
319+
320+
Use environment variables as a delivery mechanism, not as the root secret store.
321+
322+
## Recommended patterns by key type
323+
324+
### `authpaseto_secret_key`
325+
326+
- Prefer Vault, TPM, or a platform secret store.
327+
- Use Fernet only as envelope encryption around a blob protected elsewhere.
328+
- Avoid hand-written strings and repo-tracked `.env` files.
329+
330+
### `authpaseto_private_key`
331+
332+
- Treat it like any other signing private key.
333+
- Prefer TPM, Vault, or a hardened platform secret store.
334+
- Use `authpaseto_private_key_file` only when mounted files are the only viable
335+
integration point.
336+
337+
### `authpaseto_public_key`
338+
339+
- It does not require secrecy, but it still requires authenticated retrieval.
340+
- Prefer Vault, service configuration, or another trusted distribution path when
341+
you need controlled rotation across many validators.
342+
- `authpaseto_public_key_file` is acceptable more often than private-key file
343+
loading, but still weaker than a centralized authenticated distribution model.
344+
345+
## Example application
346+
347+
The following example shows a production-shaped `load_config()` callback with a
348+
safe default path and optional retrieval backends:
349+
350+
```python
351+
{!../examples/key_management.py!}
352+
```
353+
354+
## Practical defaults
355+
356+
- For small local deployments: use a platform secret store or carefully injected
357+
environment variables, not hardcoded strings.
358+
- For single-host hardened deployments: TPM for the local secret or private key
359+
is often the strongest option.
360+
- For multi-instance production: Vault or an equivalent centralized secret store
361+
is usually the most practical recommendation.
362+
- Use file loading only when the environment cannot provide a stronger retrieval
363+
interface.

0 commit comments

Comments
 (0)