pydantic-cryptography 0.1.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.
- pydantic_cryptography-0.1.0/CHANGELOG.md +40 -0
- pydantic_cryptography-0.1.0/LICENSE +21 -0
- pydantic_cryptography-0.1.0/PKG-INFO +412 -0
- pydantic_cryptography-0.1.0/README.md +381 -0
- pydantic_cryptography-0.1.0/pyproject.toml +156 -0
- pydantic_cryptography-0.1.0/pyproject.toml.orig +119 -0
- pydantic_cryptography-0.1.0/src/pydantic_cryptography/__init__.py +18 -0
- pydantic_cryptography-0.1.0/src/pydantic_cryptography/_jwk.py +361 -0
- pydantic_cryptography-0.1.0/src/pydantic_cryptography/_keys.py +800 -0
- pydantic_cryptography-0.1.0/src/pydantic_cryptography/_kinds.py +74 -0
- pydantic_cryptography-0.1.0/src/pydantic_cryptography/py.typed +0 -0
- pydantic_cryptography-0.1.0/tests/__init__.py +0 -0
- pydantic_cryptography-0.1.0/tests/helpers.py +92 -0
- pydantic_cryptography-0.1.0/tests/test_alg.py +277 -0
- pydantic_cryptography-0.1.0/tests/test_fastapi.py +44 -0
- pydantic_cryptography-0.1.0/tests/test_jwk.py +156 -0
- pydantic_cryptography-0.1.0/tests/test_loading.py +282 -0
- pydantic_cryptography-0.1.0/tests/test_readme.py +53 -0
- pydantic_cryptography-0.1.0/tests/test_settings.py +125 -0
- pydantic_cryptography-0.1.0/tests/test_types.py +367 -0
- pydantic_cryptography-0.1.0/tests/typing/mypy-pydantic-plugin.ini +7 -0
- pydantic_cryptography-0.1.0/tests/typing/usage.py +114 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
|
|
6
|
+
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Before 1.0, minor versions
|
|
7
|
+
may contain breaking changes.
|
|
8
|
+
|
|
9
|
+
## [Unreleased]
|
|
10
|
+
|
|
11
|
+
## [0.1.0] - 2026-10-03
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- `PrivateKey` and `PublicKey` Pydantic types for RSA, EC (P-256, P-384, P-521, secp256k1),
|
|
16
|
+
Ed25519, Ed448, X25519 and X448 keys, loaded from PEM or OpenSSH text. A type argument, as in
|
|
17
|
+
`PrivateKey[RSAPrivateKey]`, limits the kinds of keys accepted. RSA keys must be at least 2048
|
|
18
|
+
bits (RFC 7518), and EC keys on one of the curves that JWKs know.
|
|
19
|
+
- Settings-friendly loading: indented keys (e.g. in a triple-quoted default) and keys on one line
|
|
20
|
+
with literal `\n`s are accepted. Works with pydantic-settings.
|
|
21
|
+
- Private keys stay hidden: in their `repr`, when serialized to JSON, and in validation errors.
|
|
22
|
+
- Key fields describe themselves in their JSON schema, and so in OpenAPI: the kinds of keys and the
|
|
23
|
+
formats they accept, e.g. "An RSA private key: PEM (PKCS#8, PKCS#1 or SEC 1) or OpenSSH".
|
|
24
|
+
- JWKs and JWK Sets as Pydantic models: `public_jwk` is an `RSAPublicJWK`, `ECPublicJWK` or
|
|
25
|
+
`OKPPublicJWK`, and `JWKS.from_keys()` gives a `JWKS`, e.g. to return from a FastAPI endpoint,
|
|
26
|
+
with its OpenAPI schema. Key IDs (`kid`) are the RFC 7638 thumbprint. Checked against the RFC
|
|
27
|
+
examples and against joserfc. The default `alg` of Ed25519 and Ed448 keys is `Ed25519` or
|
|
28
|
+
`Ed448` (RFC 9864), not the deprecated `EdDSA`.
|
|
29
|
+
- `Alg(...)`, to set the algorithm a key field's keys are used with, e.g.
|
|
30
|
+
`Annotated[PrivateKey[RSAPrivateKey], Alg("RS512")]`; the key's `alg`, `use` and JWK follow it.
|
|
31
|
+
It's checked against the kinds of keys the field allows when the class is defined, and against
|
|
32
|
+
the EC curve when a key is loaded. It works on an optional field too, e.g.
|
|
33
|
+
`Annotated[PrivateKey[RSAPrivateKey] | None, Alg("RS512")]`. `load()` and the constructors take
|
|
34
|
+
an `alg` too.
|
|
35
|
+
- `PrivateKey.load()` and `PublicKey.load()`, with an optional kind (`"RSA"`, `"EC"`, ...) that
|
|
36
|
+
type checkers understand.
|
|
37
|
+
- Type-checker support: mypy, pyright and ty.
|
|
38
|
+
|
|
39
|
+
[Unreleased]: https://github.com/joakimnordling/pydantic-cryptography/compare/v0.1.0...HEAD
|
|
40
|
+
[0.1.0]: https://github.com/joakimnordling/pydantic-cryptography/releases/tag/v0.1.0
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Joakim Nordling
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pydantic-cryptography
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Pydantic types for cryptographic keys: load and validate PEM keys in settings, get JWKs and JWKS
|
|
5
|
+
Keywords: pydantic,pydantic-settings,cryptography,pem,jwk,jwks,rsa,ecdsa,ed25519
|
|
6
|
+
Author: Joakim Nordling
|
|
7
|
+
Author-email: Joakim Nordling <joakim.nordling@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Framework :: Pydantic :: 2
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Classifier: Topic :: Security :: Cryptography
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Dist: pydantic>=2.7
|
|
23
|
+
Requires-Dist: cryptography>=50.0.2
|
|
24
|
+
Requires-Dist: typing-extensions>=4.12
|
|
25
|
+
Requires-Python: >=3.11
|
|
26
|
+
Project-URL: Homepage, https://github.com/joakimnordling/pydantic-cryptography
|
|
27
|
+
Project-URL: Documentation, https://github.com/joakimnordling/pydantic-cryptography#readme
|
|
28
|
+
Project-URL: Changelog, https://github.com/joakimnordling/pydantic-cryptography/blob/main/CHANGELOG.md
|
|
29
|
+
Project-URL: Issues, https://github.com/joakimnordling/pydantic-cryptography/issues
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# pydantic-cryptography
|
|
33
|
+
|
|
34
|
+
[](https://pypi.org/project/pydantic-cryptography/)
|
|
35
|
+
[](https://pypi.org/project/pydantic-cryptography/)
|
|
36
|
+
[](https://github.com/joakimnordling/pydantic-cryptography/actions/workflows/ci.yml)
|
|
37
|
+
[](https://github.com/joakimnordling/pydantic-cryptography/blob/main/LICENSE)
|
|
38
|
+
[](https://github.com/joakimnordling/pydantic-cryptography/actions/workflows/ci.yml)
|
|
39
|
+
[](https://github.com/astral-sh/ruff)
|
|
40
|
+
|
|
41
|
+
Pydantic types for cryptographic keys.
|
|
42
|
+
|
|
43
|
+
Use private and public RSA, EC (elliptic curve), Ed25519, Ed448, X25519 and X448 keys in your
|
|
44
|
+
settings: validated when the settings load, and ready to use, e.g. to sign and verify. Want to
|
|
45
|
+
publish a JWKS (JSON Web Key Set) of your keys for OpenID Connect and OAuth clients?
|
|
46
|
+
`JWKS.from_keys()` builds it for you, with a JWK (JSON Web Key: `kty`, `kid`, `n`, `e`, …) for
|
|
47
|
+
each key.
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from cryptography.hazmat.primitives.asymmetric import ec, rsa
|
|
51
|
+
from pydantic_settings import BaseSettings
|
|
52
|
+
|
|
53
|
+
from pydantic_cryptography import PrivateKey, PublicKey
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Settings(BaseSettings):
|
|
57
|
+
signing_key: PrivateKey[rsa.RSAPrivateKey]
|
|
58
|
+
partner_key: PublicKey[ec.EllipticCurvePublicKey]
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Fully typed: mypy, pyright and ty know that `settings.signing_key.key` is an `rsa.RSAPrivateKey`.
|
|
62
|
+
|
|
63
|
+
## Why
|
|
64
|
+
|
|
65
|
+
Keys are usually kept as secrets and given to the application as environment variables or `.env`
|
|
66
|
+
files, e.g. to sign tokens, to verify a partner's signatures, or to publish in a `jwks.json` for an
|
|
67
|
+
OpenID Connect or OAuth setup. A `SecretStr` setting works, but you still have to load the key
|
|
68
|
+
before you can use it, and keep the loaded key for reuse: loading an RSA key might take tens of
|
|
69
|
+
milliseconds, as it's checked on the way. And a broken key, or one of the wrong kind, only shows up
|
|
70
|
+
when it's first used.
|
|
71
|
+
|
|
72
|
+
These types load and check each key once, when the settings load, and the loaded key is right there
|
|
73
|
+
in the settings, ready to use. A mistake is reported with the field's name, and the key is kept out
|
|
74
|
+
of reprs, JSON and error messages, so it doesn't end up in your logs by accident. For a
|
|
75
|
+
`jwks.json`, the JWK members and the key ID (`kid`) are computed for you.
|
|
76
|
+
|
|
77
|
+
## Installation
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
pip install pydantic-cryptography
|
|
81
|
+
# or
|
|
82
|
+
uv add pydantic-cryptography
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Requires Python 3.11+, Pydantic 2.7+ and cryptography 50.0.2+. The types work in any Pydantic model;
|
|
86
|
+
[pydantic-settings](https://docs.pydantic.dev/latest/concepts/pydantic_settings/) isn't required,
|
|
87
|
+
but it's where they're most useful.
|
|
88
|
+
|
|
89
|
+
## Quick start
|
|
90
|
+
|
|
91
|
+
Create a key and give it to the application as an environment variable:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out signing-key.pem
|
|
95
|
+
export SIGNING_KEY="$(cat signing-key.pem)"
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
The examples in this README continue from each other, like one script. For them, we create the key
|
|
99
|
+
in Python:
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
import os
|
|
103
|
+
|
|
104
|
+
from cryptography.hazmat.primitives import serialization
|
|
105
|
+
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
106
|
+
|
|
107
|
+
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
108
|
+
os.environ["SIGNING_KEY"] = key.private_bytes(
|
|
109
|
+
serialization.Encoding.PEM,
|
|
110
|
+
serialization.PrivateFormat.PKCS8,
|
|
111
|
+
serialization.NoEncryption(),
|
|
112
|
+
).decode()
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Declare it in the settings. The type argument limits the kinds of keys accepted; here only RSA:
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
119
|
+
from pydantic_settings import BaseSettings
|
|
120
|
+
|
|
121
|
+
from pydantic_cryptography import PrivateKey
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class Settings(BaseSettings):
|
|
125
|
+
signing_key: PrivateKey[rsa.RSAPrivateKey]
|
|
126
|
+
previous_signing_key: PrivateKey[rsa.RSAPrivateKey] | None = None # during a key rotation
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
settings = Settings()
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Sign with the key, e.g. a JWT with [PyJWT](https://pyjwt.readthedocs.io/):
|
|
133
|
+
|
|
134
|
+
<!-- readme-test: needs jwt -->
|
|
135
|
+
```python
|
|
136
|
+
import jwt
|
|
137
|
+
|
|
138
|
+
key = settings.signing_key
|
|
139
|
+
token = jwt.encode({"sub": "someone"}, key.key, algorithm=key.alg, headers={"kid": key.kid})
|
|
140
|
+
|
|
141
|
+
# and to verify it
|
|
142
|
+
claims = jwt.decode(
|
|
143
|
+
token,
|
|
144
|
+
key.public_key().key,
|
|
145
|
+
algorithms=[key.alg], # the key's own algorithm, never the one in the token
|
|
146
|
+
)
|
|
147
|
+
assert claims == {"sub": "someone"}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
And publish the public key as a JWKS, e.g. at the `jwks_uri` of your OpenID configuration. With
|
|
151
|
+
FastAPI, return it from an endpoint, and its OpenAPI schema shows the members of each kind of key:
|
|
152
|
+
|
|
153
|
+
<!-- readme-test: needs fastapi -->
|
|
154
|
+
```python
|
|
155
|
+
from fastapi import FastAPI
|
|
156
|
+
|
|
157
|
+
from pydantic_cryptography import JWKS
|
|
158
|
+
|
|
159
|
+
app = FastAPI()
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@app.get("/.well-known/jwks.json")
|
|
163
|
+
def get_jwks() -> JWKS:
|
|
164
|
+
return JWKS.from_keys(settings.signing_key, settings.previous_signing_key)
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
`JWKS` is a Pydantic model, so without FastAPI, `.model_dump_json()` gives the JSON, and
|
|
168
|
+
`.model_dump()` a dict:
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
from pydantic_cryptography import JWKS
|
|
172
|
+
|
|
173
|
+
jwks = JWKS.from_keys(settings.signing_key, settings.previous_signing_key)
|
|
174
|
+
assert jwks.keys == [settings.signing_key.public_jwk]
|
|
175
|
+
json_text = jwks.model_dump_json()
|
|
176
|
+
# {"keys":[{"kty":"RSA","kid":"...","use":"sig","alg":"RS256","n":"...","e":"AQAB"}]}
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
## The types
|
|
180
|
+
|
|
181
|
+
### `PrivateKey`
|
|
182
|
+
|
|
183
|
+
A private key. As a field, it accepts:
|
|
184
|
+
|
|
185
|
+
- PEM: PKCS#8 (`-----BEGIN PRIVATE KEY-----`), PKCS#1 (`-----BEGIN RSA PRIVATE KEY-----`) or
|
|
186
|
+
SEC 1 (`-----BEGIN EC PRIVATE KEY-----`);
|
|
187
|
+
- OpenSSH (`-----BEGIN OPENSSH PRIVATE KEY-----`), as from `ssh-keygen`;
|
|
188
|
+
- a `cryptography` private key object, or a `PrivateKey`.
|
|
189
|
+
|
|
190
|
+
Encrypted (password-protected) keys aren't supported.
|
|
191
|
+
|
|
192
|
+
The value is cleaned up the way settings values tend to need: the indentation of each line is
|
|
193
|
+
removed (a key in a triple-quoted string in a class body just works), and a key squeezed onto one
|
|
194
|
+
line with literal `\n`s (as some deployment tools require) gets its line breaks back.
|
|
195
|
+
|
|
196
|
+
Like Pydantic's `SecretStr`, it keeps the key out of sight: its `repr` shows only the kind of key
|
|
197
|
+
(`PrivateKey(RSA 2048)`), and it's serialized to JSON as `"**********"`. Validation errors don't
|
|
198
|
+
show the input either, so a private key put in the wrong field doesn't end up in your logs. As with
|
|
199
|
+
`SecretStr`, the JSON of a model with a private key can't be loaded again, as the key isn't in it.
|
|
200
|
+
|
|
201
|
+
| Attribute | What it is |
|
|
202
|
+
| --- | --- |
|
|
203
|
+
| `key` | The `cryptography` key object, e.g. `rsa.RSAPrivateKey`, to sign or decrypt with. |
|
|
204
|
+
| `public_key()` | The public key, as a `PublicKey`. |
|
|
205
|
+
| `alg` | The JWA algorithm the key is used with (see [Algorithms](#algorithms)). |
|
|
206
|
+
| `kid` | The key ID: the [RFC 7638](https://www.rfc-editor.org/rfc/rfc7638) SHA-256 JWK thumbprint of the key. |
|
|
207
|
+
| `kty` | The JWK key type: `"RSA"`, `"EC"` or `"OKP"`. |
|
|
208
|
+
| `public_jwk` | The public key as a JWK (see [JWKs](#jwks)). |
|
|
209
|
+
| `public_pem` | The public key as PEM (`-----BEGIN PUBLIC KEY-----`). |
|
|
210
|
+
| `private_pem` | The private key as PEM (PKCS#8, `-----BEGIN PRIVATE KEY-----`). |
|
|
211
|
+
| `PrivateKey(key, alg=None)` | A `PrivateKey` of a `cryptography` key object. |
|
|
212
|
+
| `PrivateKey.load(data, kind=None, *, alg=None)` | Loads a key from PEM or OpenSSH text (`str` or `bytes`) outside a model, e.g. `PrivateKey.load(pem, "RSA")`. The `kind` is `"RSA"`, `"EC"`, `"Ed25519"`, `"Ed448"`, `"X25519"` or `"X448"`. |
|
|
213
|
+
|
|
214
|
+
**Pass the key object to other libraries**, e.g. `settings.signing_key.key`, rather than the PEM.
|
|
215
|
+
PyJWT, python-jose, joserfc and jwcrypto all accept it, and loading a PEM again checks the key
|
|
216
|
+
again, which might take tens of milliseconds for an RSA key.
|
|
217
|
+
|
|
218
|
+
### `PublicKey`
|
|
219
|
+
|
|
220
|
+
A public key, e.g. a partner's, to verify their signatures with. As a field, it accepts PEM
|
|
221
|
+
(`-----BEGIN PUBLIC KEY-----` or `-----BEGIN RSA PUBLIC KEY-----`), an OpenSSH public key line
|
|
222
|
+
(`ssh-ed25519 AAAA... comment`), or a `cryptography` public key object. It's serialized to JSON as
|
|
223
|
+
PEM. It has the same attributes as `PrivateKey` (`PublicKey(key, alg=None)`, `PublicKey.load(...)`,
|
|
224
|
+
`key`, `alg`, `kid`, ...), except `public_key()` and `private_pem`.
|
|
225
|
+
|
|
226
|
+
### Limiting the kinds of keys
|
|
227
|
+
|
|
228
|
+
Without a type argument, any supported kind of key is accepted. With one, only the kinds named, and
|
|
229
|
+
type checkers know which `key` you get:
|
|
230
|
+
|
|
231
|
+
```python
|
|
232
|
+
from cryptography.hazmat.primitives.asymmetric import ec, ed448, ed25519, rsa, x448, x25519
|
|
233
|
+
from pydantic import BaseModel
|
|
234
|
+
|
|
235
|
+
from pydantic_cryptography import PrivateKey, PublicKey
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
class Keys(BaseModel):
|
|
239
|
+
any_key: PrivateKey | None = None # any of the kinds below
|
|
240
|
+
rsa_key: PrivateKey[rsa.RSAPrivateKey] | None = None
|
|
241
|
+
ec_key: PrivateKey[ec.EllipticCurvePrivateKey] | None = None
|
|
242
|
+
ed25519_key: PrivateKey[ed25519.Ed25519PrivateKey] | None = None
|
|
243
|
+
ed448_key: PrivateKey[ed448.Ed448PrivateKey] | None = None
|
|
244
|
+
x25519_key: PrivateKey[x25519.X25519PrivateKey] | None = None
|
|
245
|
+
x448_key: PrivateKey[x448.X448PrivateKey] | None = None
|
|
246
|
+
rsa_or_ec_key: PrivateKey[rsa.RSAPrivateKey | ec.EllipticCurvePrivateKey] | None = None
|
|
247
|
+
partner_key: PublicKey[ed25519.Ed25519PublicKey] | None = None
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
The public key classes are in the same modules, with `Public` in place of `Private`, e.g.
|
|
251
|
+
`ec.EllipticCurvePublicKey`.
|
|
252
|
+
|
|
253
|
+
A key of another kind is refused with an error such as `expected an RSA or EC private key, got an
|
|
254
|
+
Ed25519 private key`.
|
|
255
|
+
|
|
256
|
+
### Algorithms
|
|
257
|
+
|
|
258
|
+
A key's `.alg` is the JWA algorithm it's used with, e.g. to sign with `algorithm=key.alg`. Its JWK
|
|
259
|
+
says the same. The algorithms a key can be used with depend on its kind (and for EC keys, the
|
|
260
|
+
curve):
|
|
261
|
+
|
|
262
|
+
| Key | Default `alg` | Others |
|
|
263
|
+
| --- | --- | --- |
|
|
264
|
+
| RSA | RS256 | RS384, RS512, PS256, PS384, PS512, RSA-OAEP, RSA-OAEP-256, RSA-OAEP-384, RSA-OAEP-512 |
|
|
265
|
+
| EC P-256 | ES256 | ECDH-ES, ECDH-ES+A128KW, ECDH-ES+A192KW, ECDH-ES+A256KW, HPKE-0, HPKE-7, HPKE-0-KE, HPKE-7-KE |
|
|
266
|
+
| EC P-384 | ES384 | ECDH-ES, ECDH-ES+A128KW, ECDH-ES+A192KW, ECDH-ES+A256KW, HPKE-1, HPKE-1-KE |
|
|
267
|
+
| EC P-521 | ES512 | ECDH-ES, ECDH-ES+A128KW, ECDH-ES+A192KW, ECDH-ES+A256KW, HPKE-2, HPKE-2-KE |
|
|
268
|
+
| EC secp256k1 | ES256K | |
|
|
269
|
+
| Ed25519 | Ed25519 | EdDSA |
|
|
270
|
+
| Ed448 | Ed448 | EdDSA |
|
|
271
|
+
| X25519 | ECDH-ES | ECDH-ES+A128KW, ECDH-ES+A192KW, ECDH-ES+A256KW, HPKE-3, HPKE-4, HPKE-3-KE |
|
|
272
|
+
| X448 | ECDH-ES | ECDH-ES+A128KW, ECDH-ES+A192KW, ECDH-ES+A256KW, HPKE-5, HPKE-6, HPKE-5-KE |
|
|
273
|
+
|
|
274
|
+
The RSA-OAEP, ECDH-ES and HPKE algorithms encrypt (the JWK's `use` is `enc`); the others sign.
|
|
275
|
+
|
|
276
|
+
Keys on other EC curves, e.g. brainpoolP256r1, are refused: JWKs have no representation for them,
|
|
277
|
+
so they'd have no algorithm or key ID either.
|
|
278
|
+
|
|
279
|
+
**An algorithm other than the default**, e.g. RS512 or RSA-PSS for an RSA key, is set with `Alg(...)` in the field's
|
|
280
|
+
annotation:
|
|
281
|
+
|
|
282
|
+
```python
|
|
283
|
+
from typing import Annotated
|
|
284
|
+
|
|
285
|
+
from pydantic_cryptography import Alg
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
class PSSSettings(BaseSettings):
|
|
289
|
+
signing_key: Annotated[PrivateKey[rsa.RSAPrivateKey], Alg("PS256")]
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
pss_key = PSSSettings().signing_key
|
|
293
|
+
assert pss_key.alg == pss_key.public_jwk.alg == "PS256"
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
The algorithm must fit every kind of key the field allows: `Alg("PS256")` on a plain `PrivateKey`
|
|
297
|
+
raises a `TypeError` when the class is defined. For EC keys, it's also checked against the curve
|
|
298
|
+
when a key is loaded, e.g. ES384 needs a P-384 key. It works on an optional field too:
|
|
299
|
+
`Annotated[PrivateKey[rsa.RSAPrivateKey] | None, Alg("PS256")]`. Outside a model, `load()` and the
|
|
300
|
+
constructor take it as well: `PrivateKey.load(pem, "RSA", alg="PS256")`.
|
|
301
|
+
|
|
302
|
+
For Ed25519 and Ed448 keys, the defaults are the fully-specified algorithms of
|
|
303
|
+
[RFC 9864](https://www.rfc-editor.org/rfc/rfc9864), which deprecates the older `EdDSA`. Not every
|
|
304
|
+
library knows them yet: PyJWT doesn't as of 2.15 ([issue](https://github.com/jpadilla/pyjwt/issues/1190),
|
|
305
|
+
[pull request](https://github.com/jpadilla/pyjwt/pull/1199)), and it can't use a JWK that has them.
|
|
306
|
+
Until your verifiers do, use `Alg("EdDSA")`, and sign with `algorithm=key.alg` as usual.
|
|
307
|
+
|
|
308
|
+
The names of the kinds of keys (`"RSA"`, `"EC"`, ...) and of the algorithms (`"RS256"`, ...) are
|
|
309
|
+
typed as `KindName` and `AlgName`, which you can import too, e.g. for a value from your own config.
|
|
310
|
+
|
|
311
|
+
## JWKs
|
|
312
|
+
|
|
313
|
+
`.public_jwk` is a Pydantic model of the key's JWK: an `RSAPublicJWK` (with `n` and `e`), an
|
|
314
|
+
`ECPublicJWK` (`crv`, `x` and `y`) or an `OKPPublicJWK` (`crv` and `x`), each also with `kty`, the
|
|
315
|
+
key ID as `kid`, and `use` and `alg`. `use` follows from `alg`: `enc` for the algorithms that
|
|
316
|
+
encrypt, `sig` for the others. `isinstance` tells the kinds apart:
|
|
317
|
+
|
|
318
|
+
```python
|
|
319
|
+
from pydantic_cryptography import RSAPublicJWK
|
|
320
|
+
|
|
321
|
+
jwk = settings.signing_key.public_jwk
|
|
322
|
+
assert isinstance(jwk, RSAPublicJWK)
|
|
323
|
+
assert (jwk.kty, jwk.use, jwk.alg, jwk.e) == ("RSA", "sig", "RS256", "AQAB")
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
`JWKS.from_keys()` takes any number of keys (`PrivateKey` or `PublicKey`). `None` is skipped, so you
|
|
327
|
+
can pass an optional setting directly:
|
|
328
|
+
|
|
329
|
+
```python
|
|
330
|
+
assert JWKS.from_keys(pss_key, settings.previous_signing_key).keys == [pss_key.public_jwk]
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
A key that's already in the set is left out. The same key with two different algorithms raises an
|
|
334
|
+
error, as a verifier couldn't tell which one to use.
|
|
335
|
+
|
|
336
|
+
Both are Pydantic models: `.model_dump_json()` gives the JSON, and `.model_dump()` a dict.
|
|
337
|
+
|
|
338
|
+
## Settings tips
|
|
339
|
+
|
|
340
|
+
**Multi-line values.** Environment variables can hold line breaks, and so can double-quoted values
|
|
341
|
+
in a `.env` file. Where that's awkward, write the key on one line with `\n` in place of the line
|
|
342
|
+
breaks. Kubernetes and Docker secrets mounted as files work with pydantic-settings' `secrets_dir`.
|
|
343
|
+
|
|
344
|
+
**Several keys**, e.g. for a key rotation, go in a list. As for any list in pydantic-settings, the
|
|
345
|
+
environment variable is then a JSON array, where the line breaks of a key are written as `\n`:
|
|
346
|
+
`SIGNING_KEYS='["-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----\n"]'`.
|
|
347
|
+
|
|
348
|
+
```python
|
|
349
|
+
import json
|
|
350
|
+
|
|
351
|
+
from pydantic import Field
|
|
352
|
+
|
|
353
|
+
os.environ["SIGNING_KEYS"] = json.dumps([settings.signing_key.private_pem])
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
class RotatingSettings(BaseSettings):
|
|
357
|
+
# The first key signs, and all of them are published. To rotate, add the new key last, so it's
|
|
358
|
+
# published before anything is signed with it; then move it first; and remove the old key once
|
|
359
|
+
# nothing signed with it is in use any more.
|
|
360
|
+
signing_keys: list[PrivateKey[rsa.RSAPrivateKey]] = Field(min_length=1)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
rotating = RotatingSettings()
|
|
364
|
+
signing_key = rotating.signing_keys[0]
|
|
365
|
+
assert JWKS.from_keys(*rotating.signing_keys).keys == [signing_key.public_jwk]
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
A mistake in one of the keys is reported with its position, e.g. `signing_keys.1`.
|
|
369
|
+
|
|
370
|
+
**A default key**, e.g. for development, can be given as text. `PrivateKey.load()` with the kind
|
|
371
|
+
keeps type checkers happy, as they don't accept a string for the field. Keep such defaults out of
|
|
372
|
+
the production settings: there, a missing environment variable would mean signing with a key that's
|
|
373
|
+
in your repository.
|
|
374
|
+
|
|
375
|
+
```python
|
|
376
|
+
DEVELOPMENT_KEY = settings.signing_key.private_pem # yours would be a literal PEM string
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
class DevelopmentSettings(BaseSettings):
|
|
380
|
+
signing_key: PrivateKey[rsa.RSAPrivateKey] = PrivateKey.load(DEVELOPMENT_KEY, "RSA")
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
**A new key on each start**, when nothing needs to survive a restart. Each process gets its own
|
|
384
|
+
key, so with several workers or instances, one can't verify what another signed:
|
|
385
|
+
|
|
386
|
+
```python
|
|
387
|
+
from cryptography.hazmat.primitives.asymmetric import ed25519
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
class EphemeralSettings(BaseSettings):
|
|
391
|
+
session_key: PrivateKey[ed25519.Ed25519PrivateKey] = PrivateKey(
|
|
392
|
+
ed25519.Ed25519PrivateKey.generate()
|
|
393
|
+
)
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
## Limitations
|
|
397
|
+
|
|
398
|
+
- Encrypted (password-protected) private keys aren't supported. Decrypt one with `cryptography`
|
|
399
|
+
(`load_pem_private_key(data, password)`) and pass the key object.
|
|
400
|
+
- DSA keys, and other kinds not listed above, are refused.
|
|
401
|
+
- RSA keys must be at least 2048 bits, as RFC 7518 requires for all the RSA algorithms.
|
|
402
|
+
- EC keys must be on the curves P-256, P-384, P-521 or secp256k1, the ones JWKs know.
|
|
403
|
+
- JWKs are output only: keys aren't loaded from JWKs, and there's no JWK of a private key.
|
|
404
|
+
- X.509 certificates aren't accepted: a `PublicKey` takes the key itself. Take it out of a
|
|
405
|
+
certificate with `cryptography` (`load_pem_x509_certificate(data).public_key()`) and pass the key
|
|
406
|
+
object.
|
|
407
|
+
- PyJWT (as of 2.15) doesn't know the Ed25519 and Ed448 algorithms; see [Algorithms](#algorithms)
|
|
408
|
+
for the workaround.
|
|
409
|
+
|
|
410
|
+
## License
|
|
411
|
+
|
|
412
|
+
[MIT](LICENSE)
|