diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c8d5bbe..a3ff79b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -26,11 +26,11 @@ repos: hooks: - id: blacken-docs - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.1.11 + rev: v0.1.13 hooks: - id: ruff args: [ --fix ] - - id: ruff-format + - id: ruff-format - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.8.0 hooks: @@ -44,6 +44,6 @@ repos: additional_dependencies: - types-cryptography==3.3.23.2 - pytest-mypy==0.10.3 - - binapy==0.7.0 + - binapy==0.8.0 - freezegun==1.2.2 - jwcrypto==1.5.0 diff --git a/README.md b/README.md index 95787cc..25f5b4a 100644 --- a/README.md +++ b/README.md @@ -1,327 +1,329 @@ -# JwSkate - -[![PyPi](https://img.shields.io/pypi/v/jwskate.svg)](https://pypi.python.org/pypi/jwskate) -[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) - -A Pythonic implementation of the JOSE set of IETF specifications: [Json Web Signature][rfc7515], [Keys][rfc7517], -[Algorithms][rfc7518], [Tokens][rfc7519] and [Encryption][rfc7516] (RFC7515 to 7519), and their extensions -[ECDH Signatures][rfc8037] (RFC8037), [JWK Thumbprints][rfc7638] (RFC7638), and [JWK Thumbprint URI][rfc9278] (RFC9278), -and with respects to [JWT Best Current Practices][rfc8725] (RFC8725). - -- Free software: MIT -- Documentation: [https://guillp.github.io/jwskate/](https://guillp.github.io/jwskate/) - -Here is a quick usage example: generating a private RSA key, signing some data, then validating that signature with the matching public key: - -```python -from jwskate import Jwk - -# Let's generate a random private key, to use with alg 'RS256'. -# Based on that alg, jwskate knows it must be an RSA key. -# RSA keys can be of any size, so let's pass the requested key size as parameter -rsa_private_jwk = Jwk.generate(alg="RS256", key_size=2048) - -data = b"Signing is easy!" # we will sign this -signature = rsa_private_jwk.sign(data) # done! - -print(signature) -# b'-\xe89\x81\xc4\xb9.G\x11\xa6\x93/dm\xf0\xc8\x0f\xd....' - -# now extract the public key, and verify the signature with it -rsa_public_jwk = rsa_private_jwk.public_jwk() -assert rsa_public_jwk.verify(data, signature) - -# let's see what a `Jwk` looks like: -assert isinstance(rsa_private_jwk, dict) # Jwk are dict subclasses - -print(rsa_private_jwk.with_usage_parameters()) -``` - -The result of this print will look like this (with the random parts abbreviated to `...` for display purposes only): - -``` -{'kty': 'RSA', - 'n': '...', - 'e': 'AQAB', - 'd': '...', - 'p': '...', - 'q': '...', - 'dp': '...', - 'dq': '...', - 'qi': '...', - 'alg': 'RS256', - 'kid': '...', - 'use': 'sig', - 'key_ops': ['sign']} -``` - -Now let's sign a JWT containing arbitrary claims, this time using an Elliptic Curve (`EC`) key: - -```python -from jwskate import Jwk, Jwt - -# This time let's try an EC key, based on `alg` parameter, -# and let's specify an arbitrary Key ID (kid). -# additional args are either options (like 'key_size' above for RSA keys) -# or additional parameters to include in the JWK -private_jwk = Jwk.generate(alg="ES256", kid="my_key") -# note that based only on the `alg` value, the appropriate key type and curve -# are automatically deduced and included in the JWK -print(private_jwk) -# {'kty': 'EC', 'crv': 'P-256', 'x': 'Ppe...', 'y': '9Si...', 'd': 'g09...', 'alg': 'ES256'} -assert private_jwk.kty == "EC" -assert private_jwk.crv == "P-256" -assert private_jwk.alg == "ES256" -# this is a private key and 'ES256' is a signature alg, so 'use' and 'key_ops' can also be deduced: -assert private_jwk.use == "sig" -assert private_jwk.key_ops == ("sign",) - -# here are the claims to sign in a JWT: -claims = {"sub": "some_sub", "claim1": "value1"} - -jwt = Jwt.sign(claims, private_jwk) -# that's it! we have a signed JWT. -print(jwt) -# eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJzb21lX3N1YiIsImNsYWltMSI6InZhbHVlMSJ9.SBQIlGlFdwoEMViWUFsBmCsXShtOq4lnp3Im5ZVh1PFCGJFdW-dTG9qJjlFSAA_BkM5PF9u38PL7Ai9cC2_DJw -assert isinstance(jwt, Jwt) # Jwt are objects -assert jwt.claims == claims # claims can be accessed as a dict -assert jwt.headers == {"typ": "JWT", "alg": "ES256", "kid": "my_key"} # headers too -assert jwt.sub == "some_sub" # individual claims can be accessed as attributes -assert jwt["claim1"] == "value1" # or as dict items (with "subscription") -assert jwt.alg == "ES256" # alg and kid headers are also accessible as attributes -assert jwt.kid == private_jwk.kid -# notice that alg and kid are automatically set with appropriate values taken from our private jwk -assert isinstance(jwt.signature, bytes) # signature is accessible too -# verifying the jwt signature is as easy as: -assert jwt.verify_signature(private_jwk.public_jwk()) -# since our jwk contains an 'alg' parameter (here 'ES256'), the signature is automatically verified using that alg -# you could also specify an alg manually, useful for keys with no "alg" hint: -assert jwt.verify_signature(private_jwk.public_jwk(), alg="ES256") -# note that jwskate will only trust the alg(s) you provide as parameter, either part of the JWK -# or with `alg` or `algs` params, and will ignore the 'alg' that is set in the JWT, for security reasons. -``` - -Now let's sign a JWT with the standardized lifetime, subject, audience and ID claims, plus arbitrary custom claims: - -```python -from jwskate import Jwk, JwtSigner - -private_jwk = Jwk.generate(alg="ES256") -signer = JwtSigner(issuer="https://myissuer.com", key=private_jwk) -jwt = signer.sign( - subject="some_sub", - audience="some_aud", - extra_claims={"custom_claim1": "value1", "custom_claim2": "value2"}, -) - -print(jwt.claims) -``` - -The generated JWT will include the standardized claims (`iss`, `aud`, `sub`, `iat`, `exp` and `jti`), -together with the `extra_claims` provided to `.sign()`: - -``` -{'custom_claim1': 'value1', - 'custom_claim2': 'value2', - 'iss': 'https://myissuer.com', - 'aud': 'some_aud', - 'sub': 'some_sub', - 'iat': 1648823184, - 'exp': 1648823244, - 'jti': '3b400e27-c111-4013-84e0-714acd76bf3a' -} -``` - -## Features - -- Simple, Clean, Pythonic interface -- Convenience wrappers around `cryptography` for all algorithms described in JWA -- Json Web Keys (JWK) loading, dumping and generation -- Arbitrary data signature and verification using Json Web Keys -- Json Web Signatures (JWS) signing and verification -- Json Web Encryption (JWE) encryption and decryption -- Json Web Tokens (JWT) signing, verification and validation -- 100% type annotated, verified with `mypy --strict` -- nearly 100% code coverage -- Relies on [cryptography](https://cryptography.io) for all cryptographic operations -- Relies on [BinaPy](https://guillp.github.io/binapy/) for binary data manipulations - -### Supported Token Types - - -| Token Type | Support | -| ------------------------- | -------------------------------------------------------- | -| Json Web Signature (JWS) | ☑ Compact
☑ JSON Flat
☑ JSON General
| -| Json Web Encryption (JWE) | ☑ Compact
☐ JSON Flat
☐ JSON General
| -| Json Web Tokens (JWT) | ☑ Signed
☑ Signed and Encrypted | - -### Supported Signature algorithms - - -| Signature Alg | Description | Key Type | Reference | Note | -|-----------------|--------------------------------------------------|----------| ---------------------------------- | ------------------------------ | -| `HS256` | HMAC using SHA-256 | `oct` | [RFC7518, Section 3.2] | | -| `HS384` | HMAC using SHA-384 | `oct` | [RFC7518, Section 3.2] | | -| `HS512` | HMAC using SHA-512 | `oct` | [RFC7518, Section 3.2] | | -| `RS256` | RSASSA-PKCS1-v1_5 using SHA-256 | `RSA` | [RFC7518, Section 3.3] | | -| `RS384` | RSASSA-PKCS1-v1_5 using SHA-384 | `RSA` | [RFC7518, Section 3.3] | | -| `RS512` | RSASSA-PKCS1-v1_5 using SHA-512 | `RSA` | [RFC7518, Section 3.3] | | -| `PS256` | RSASSA-PSS using SHA-256 and MGF1 with SHA-256 | `RSA` | [RFC7518, Section 3.5] | | -| `PS384` | RSASSA-PSS using SHA-384 and MGF1 with SHA-384 | `RSA` | [RFC7518, Section 3.5] | | -| `PS512` | RSASSA-PSS using SHA-512 and MGF1 with SHA-512 | `RSA` | [RFC7518, Section 3.5] | | -| `ES256` | ECDSA using P-256 and SHA-256 | `EC` | [RFC7518, Section 3.4] | | -| `ES384` | ECDSA using P-384 and SHA-384 | `EC` | [RFC7518, Section 3.4] | | -| `ES512` | ECDSA using P-521 and SHA-512 | `EC` | [RFC7518, Section 3.4] | | -| `ES256K` | ECDSA using secp256k1 curve and SHA-256 | `EC` | [RFC8812, Section 3.2] | | -| `EdDSA` | EdDSA signature algorithms | `OKP` | [RFC8037, Section 3.1] | Ed2219 and Ed448 are supported | -| `HS1` | HMAC using SHA-1 | `oct` | https://www.w3.org/TR/WebCryptoAPI | Validation Only | -| `RS1` | RSASSA-PKCS1-v1_5 with SHA-1 | `RSA` | https://www.w3.org/TR/WebCryptoAPI | Validation Only | -| `none` | No digital signature or MAC performed | | [RFC7518, Section 3.6] | Not usable by mistake | - -### Supported Encryption algorithms - - -| Signature Alg | Description | Reference | -|-----------------|-------------------------------------------------------------|--------------------------| -| `A128CBC-HS256` | AES_128_CBC_HMAC_SHA_256 authenticated encryption algorithm | [RFC7518, Section 5.2.3] | -| `A192CBC-HS384` | AES_192_CBC_HMAC_SHA_384 authenticated encryption algorithm | [RFC7518, Section 5.2.4] | -| `A256CBC-HS512` | AES_256_CBC_HMAC_SHA_512 authenticated encryption algorithm | [RFC7518, Section 5.2.5] | -| `A128GCM` | AES GCM using 128-bit key | [RFC7518, Section 5.3] | -| `A192GCM` | AES GCM using 192-bit key | [RFC7518, Section 5.3] | -| `A256GCM` | AES GCM using 256-bit key | [RFC7518, Section 5.3] | - - -### Supported Key Management algorithms - - -| Signature Alg | Description | Key Type | Reference | Note | -| ------------------ | ---------------------------------------------- |---------------| ---------------------------------- | ----------- | -| `RSA1_5` | RSAES-PKCS1-v1_5 | `RSA` | [RFC7518, Section 4.2] | Unwrap Only | -| `RSA-OAEP` | RSAES OAEP using default parameters | `RSA` | [RFC7518, Section 4.3] | | -| `RSA-OAEP-256` | RSAES OAEP using SHA-256 and MGF1 with SHA-256 | `RSA` | [RFC7518, Section 4.3] | | -| `RSA-OAEP-384` | RSA-OAEP using SHA-384 and MGF1 with SHA-384 | `RSA` | https://www.w3.org/TR/WebCryptoAPI | | -| `RSA-OAEP-512` | RSA-OAEP using SHA-512 and MGF1 with SHA-512 | `RSA` | https://www.w3.org/TR/WebCryptoAPI | | -| `A128KW` | AES Key Wrap using 128-bit key | `oct` | [RFC7518, Section 4.4] | | -| `A192KW` | AES Key Wrap using 192-bit key | `oct` | [RFC7518, Section 4.4] | | -| `A256KW` | AES Key Wrap using 256-bit key | `oct` | [RFC7518, Section 4.4] | | -| `A128GCMKW` | Key wrapping with AES GCM using 128-bit key | `oct` | [RFC7518, Section 4.7] | | -| `A192GCMKW` | Key wrapping with AES GCM using 192-bit key | `oct` | [RFC7518, Section 4.7] | | -| `A256GCMKW` | Key wrapping with AES GCM using 256-bit key | `oct` | [RFC7518, Section 4.7] | | -| `dir` | Direct use of a shared symmetric key | `oct` | [RFC7518, Section 4.5] | | -| `ECDH-ES` | ECDH-ES using Concat KDF | `EC` | [RFC7518, Section 4.6] | | -| `ECDH-ES+A128KW` | ECDH-ES using Concat KDF and "A128KW" wrapping | `EC` | [RFC7518, Section 4.6] | | -| `ECDH-ES+A192KW` | ECDH-ES using Concat KDF and "A192KW" wrapping | `EC` | [RFC7518, Section 4.6] | | -| `ECDH-ES+A256KW` | ECDH-ES using Concat KDF and "A256KW" wrapping | `EC` | [RFC7518, Section 4.6] | | -| `PBES2-HS256+A128KW` | PBES2 with HMAC SHA-256 and "A128KW" wrapping | `password` | [RFC7518, Section 4.8] | | -| `PBES2-HS384+A192KW` | PBES2 with HMAC SHA-384 and "A192KW" wrapping | `password` | [RFC7518, Section 4.8] | | -| `PBES2-HS512+A256KW` | PBES2 with HMAC SHA-512 and "A256KW" wrapping | `password` | [RFC7518, Section 4.8] | | - - - -### Supported Elliptic Curves - - -| Curve | Description | Key Type | Usage | Reference | -|-------------|---------------------------------------|----------| --------------------- | -------------------------- | -| `P-256` | P-256 Curve | `EC` | signature, encryption | [RFC7518, Section 6.2.1.1] | -| `P-384` | P-384 Curve | `EC` | signature, encryption | [RFC7518, Section 6.2.1.1] | -| `P-521` | P-521 Curve | `EC` | signature, encryption | [RFC7518, Section 6.2.1.1] | -| `secp256k1` | SECG secp256k1 curve | `EC` | signature, encryption | [RFC8812, Section 3.1] | -| `Ed25519` | Ed25519 signature algorithm key pairs | `OKP` | signature | [RFC8037, Section 3.1] | -| `Ed448` | Ed448 signature algorithm key pairs | `OKP` | signature | [RFC8037, Section 3.1] | -| `X25519` | X25519 function key pairs | `OKP` | encryption | [RFC8037, Section 3.2] | -| `X448` | X448 function key pairs | `OKP` | encryption | [RFC8037, Section 3.2] | - -## Why a new lib? - -There are already multiple modules implementing JOSE and Json Web Crypto related specifications in Python. However, I -have been dissatisfied by all of them so far, so I decided to come up with my own module. - -- [PyJWT](https://pyjwt.readthedocs.io) -- [JWCrypto](https://jwcrypto.readthedocs.io/) -- [Python-JOSE](https://python-jose.readthedocs.io/) -- [AuthLib](https://docs.authlib.org/en/latest/jose/) - -Not to say that those are _bad_ libs (I actually use `jwcrypto` myself for `jwskate` unit tests), but they either don't -support some important features, lack documentation, or more generally have APIs that don't feel easy-enough, Pythonic-enough -to use. - -## Design - -### JWK are dicts - -JWK are specified as JSON objects, which are parsed as `dict` in Python. The `Jwk` class in `jwskate` is actually a -`dict` subclass, so you can use it exactly like you would use a `dict`: you can access its members, dump it back as JSON, -etc. The same is true for Signed or Encrypted Json Web tokens in JSON format. However, you cannot change the key cryptographic -materials, since that would lead to unusable keys. - -### JWA Wrappers - -You can use `cryptography` to do the cryptographic operations that are described in -[JWA](https://www.rfc-editor.org/info/rfc7518), but since `cryptography` is a general purpose library, its usage is not -straightforward and gives you plenty of options to carefully select and combine, leaving room for mistakes, errors and -confusion. It has also a quite inconsistent API to handle the different type of keys and algorithms. To work around -this, `jwskate` comes with a set of consistent wrappers that implement the exact JWA specifications, with minimum risk -of mistakes. - -### Safe Signature Verification - -As advised in [JWT Best Practices][rfc8725] $3.1: - -For every signature verification method in `jwskate`, the expected signature(s) algorithm(s) must be specified. That is -to avoid a security flaw where your application accepts tokens with a weaker encryption scheme than what your security -policy mandates; or even worse, where it accepts unsigned tokens, or tokens that are symmetrically signed with an -improperly used public key, leaving your application exposed to exploitation by attackers. - -To specify which signature algorithms are accepted, each signature verification method accepts, in order of preference: - -- an `alg` parameter which contains the expected algorithm, or an `algs` parameter which contains a list of acceptable - algorithms -- the `alg` parameter from the signature verification `Jwk`, if present. This `alg` is the algorithm intended for use - with that key. - -Note that you cannot use `alg` and `algs` at the same time. If your `Jwk` contains an `alg` parameter, and you provide -an `alg` or `algs` which does not match that value, a `Warning` will be emitted. - -## TODO - -- Complete/enhance/proof-read documentation -- Better exceptions (create dedicated exception classes, better messages, etc.) -- Support for JWE in JSON format -- Better tests -- Support for Selective-Disclosure JWT - -## Credits - -All cryptographic operations are handled by [cryptography](https://cryptography.io). - -[rfc7515]: https://www.rfc-editor.org/rfc/rfc7515.html -[rfc7516]: https://www.rfc-editor.org/rfc/rfc7516.html -[rfc7517]: https://www.rfc-editor.org/rfc/rfc7517.html -[rfc7518]: https://www.rfc-editor.org/rfc/rfc7518.html -[rfc7518, section 3.2]: https://www.rfc-editor.org/rfc/rfc7518.html#section-3.2 -[rfc7518, section 3.3]: https://www.rfc-editor.org/rfc/rfc7518.html#section-3.3 -[rfc7518, section 3.4]: https://www.rfc-editor.org/rfc/rfc7518.html#section-3.4 -[rfc7518, section 3.5]: https://www.rfc-editor.org/rfc/rfc7518.html#section-3.5 -[rfc7518, section 3.6]: https://www.rfc-editor.org/rfc/rfc7518.html#section-3.6 -[rfc7518, section 4.2]: https://www.rfc-editor.org/rfc/rfc7518.html#section-4.2 -[rfc7518, section 4.3]: https://www.rfc-editor.org/rfc/rfc7518.html#section-4.3 -[rfc7518, section 4.4]: https://www.rfc-editor.org/rfc/rfc7518.html#section-4.4 -[rfc7518, section 4.5]: https://www.rfc-editor.org/rfc/rfc7518.html#section-4.5 -[rfc7518, section 4.6]: https://www.rfc-editor.org/rfc/rfc7518.html#section-4.6 -[rfc7518, section 4.7]: https://www.rfc-editor.org/rfc/rfc7518.html#section-4.7 -[rfc7518, section 4.8]: https://www.rfc-editor.org/rfc/rfc7518.html#section-4.8 -[rfc7518, section 5.2.3]: https://www.rfc-editor.org/rfc/rfc7518.html#section-5.2.3 -[rfc7518, section 5.2.4]: https://www.rfc-editor.org/rfc/rfc7518.html#section-5.2.4 -[rfc7518, section 5.2.5]: https://www.rfc-editor.org/rfc/rfc7518.html#section-5.2.5 -[rfc7518, section 5.3]: https://www.rfc-editor.org/rfc/rfc7518.html#section-5.3 -[rfc7518, section 6.2.1.1]: https://www.rfc-editor.org/rfc/rfc7518.html#section-6.2.1.1 -[rfc7519]: https://www.rfc-editor.org/rfc/rfc7519.html -[rfc7638]: https://www.rfc-editor.org/rfc/rfc7638.html -[rfc8037]: https://www.rfc-editor.org/rfc/rfc8037.html -[rfc8037, section 3.1]: https://www.rfc-editor.org/rfc/rfc8037.html#section-3.1 -[rfc8037, section 3.2]: https://www.rfc-editor.org/rfc/rfc8037.html#section-3.2 -[rfc8725]: https://www.rfc-editor.org/rfc/rfc8725 -[rfc8812, section 3.1]: https://www.rfc-editor.org/rfc/rfc8812.html#section-3.1 -[rfc8812, section 3.2]: https://www.rfc-editor.org/rfc/rfc8812.html#name-ecdsa-signature-with-secp25 -[rfc9278]: https://www.rfc-editor.org/rfc/rfc9278.html +# JwSkate + +[![PyPi](https://img.shields.io/pypi/v/jwskate.svg)](https://pypi.python.org/pypi/jwskate) +[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) + +A Pythonic implementation of the JOSE set of IETF specifications: [Json Web Signature][rfc7515], [Keys][rfc7517], +[Algorithms][rfc7518], [Tokens][rfc7519] and [Encryption][rfc7516] (RFC7515 to 7519), and their extensions +[ECDH Signatures][rfc8037] (RFC8037), [JWK Thumbprints][rfc7638] (RFC7638), and [JWK Thumbprint URI][rfc9278] (RFC9278), +and with respects to [JWT Best Current Practices][rfc8725] (RFC8725). + +- Free software: MIT +- Documentation: [https://guillp.github.io/jwskate/](https://guillp.github.io/jwskate/) + +Here is a quick usage example: generating a private RSA key, signing some data, then validating that signature with the matching public key: + +```python +from jwskate import Jwk + +# Let's generate a random private key, to use with alg 'RS256'. +# Based on that alg, jwskate knows it must be an RSA key. +# RSA keys can be of any size, so let's pass the requested key size as parameter +rsa_private_jwk = Jwk.generate(alg="RS256", key_size=2048) + +data = b"Signing is easy!" # we will sign this +signature = rsa_private_jwk.sign(data) # done! + +print(signature) +# b'-\xe89\x81\xc4\xb9.G\x11\xa6\x93/dm\xf0\xc8\x0f\xd....' + +# now extract the public key, and verify the signature with it +rsa_public_jwk = rsa_private_jwk.public_jwk() +assert rsa_public_jwk.verify(data, signature) + +# let's see what a `Jwk` looks like: +from collections import UserDict + +assert isinstance(rsa_private_jwk, UserDict) # Jwk are UserDicts + +print(rsa_private_jwk.with_usage_parameters()) +``` + +The result of this print will look like this (with the random parts abbreviated to `...` for display purposes only): + +``` +{'kty': 'RSA', + 'n': '...', + 'e': 'AQAB', + 'd': '...', + 'p': '...', + 'q': '...', + 'dp': '...', + 'dq': '...', + 'qi': '...', + 'alg': 'RS256', + 'kid': '...', + 'use': 'sig', + 'key_ops': ['sign']} +``` + +Now let's sign a JWT containing arbitrary claims, this time using an Elliptic Curve (`EC`) key: + +```python +from jwskate import Jwk, Jwt + +# This time let's try an EC key, based on `alg` parameter, +# and let's specify an arbitrary Key ID (kid). +# additional args are either options (like 'key_size' above for RSA keys) +# or additional parameters to include in the JWK +private_jwk = Jwk.generate(alg="ES256", kid="my_key") +# note that based only on the `alg` value, the appropriate key type and curve +# are automatically deduced and included in the JWK +print(private_jwk) +# {'kty': 'EC', 'crv': 'P-256', 'x': 'Ppe...', 'y': '9Si...', 'd': 'g09...', 'alg': 'ES256'} +assert private_jwk.kty == "EC" +assert private_jwk.crv == "P-256" +assert private_jwk.alg == "ES256" +# this is a private key and 'ES256' is a signature alg, so 'use' and 'key_ops' can also be deduced: +assert private_jwk.use == "sig" +assert private_jwk.key_ops == ("sign",) + +# here are the claims to sign in a JWT: +claims = {"sub": "some_sub", "claim1": "value1"} + +jwt = Jwt.sign(claims, private_jwk) +# that's it! we have a signed JWT. +print(jwt) +# eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJzb21lX3N1YiIsImNsYWltMSI6InZhbHVlMSJ9.SBQIlGlFdwoEMViWUFsBmCsXShtOq4lnp3Im5ZVh1PFCGJFdW-dTG9qJjlFSAA_BkM5PF9u38PL7Ai9cC2_DJw +assert isinstance(jwt, Jwt) # Jwt are objects +assert jwt.claims == claims # claims can be accessed as a dict +assert jwt.headers == {"typ": "JWT", "alg": "ES256", "kid": "my_key"} # headers too +assert jwt.sub == "some_sub" # individual claims can be accessed as attributes +assert jwt["claim1"] == "value1" # or as dict items (with "subscription") +assert jwt.alg == "ES256" # alg and kid headers are also accessible as attributes +assert jwt.kid == private_jwk.kid +# notice that alg and kid are automatically set with appropriate values taken from our private jwk +assert isinstance(jwt.signature, bytes) # signature is accessible too +# verifying the jwt signature is as easy as: +assert jwt.verify_signature(private_jwk.public_jwk()) +# since our jwk contains an 'alg' parameter (here 'ES256'), the signature is automatically verified using that alg +# you could also specify an alg manually, useful for keys with no "alg" hint: +assert jwt.verify_signature(private_jwk.public_jwk(), alg="ES256") +# note that jwskate will only trust the alg(s) you provide as parameter, either part of the JWK +# or with `alg` or `algs` params, and will ignore the 'alg' that is set in the JWT, for security reasons. +``` + +Now let's sign a JWT with the standardized lifetime, subject, audience and ID claims, plus arbitrary custom claims: + +```python +from jwskate import Jwk, JwtSigner + +private_jwk = Jwk.generate(alg="ES256") +signer = JwtSigner(issuer="https://myissuer.com", key=private_jwk) +jwt = signer.sign( + subject="some_sub", + audience="some_aud", + extra_claims={"custom_claim1": "value1", "custom_claim2": "value2"}, +) + +print(jwt.claims) +``` + +The generated JWT will include the standardized claims (`iss`, `aud`, `sub`, `iat`, `exp` and `jti`), +together with the `extra_claims` provided to `.sign()`: + +``` +{'custom_claim1': 'value1', + 'custom_claim2': 'value2', + 'iss': 'https://myissuer.com', + 'aud': 'some_aud', + 'sub': 'some_sub', + 'iat': 1648823184, + 'exp': 1648823244, + 'jti': '3b400e27-c111-4013-84e0-714acd76bf3a' +} +``` + +## Features + +- Simple, Clean, Pythonic interface +- Convenience wrappers around `cryptography` for all algorithms described in JWA +- Json Web Keys (JWK) loading, dumping and generation +- Arbitrary data signature and verification using Json Web Keys +- Json Web Signatures (JWS) signing and verification +- Json Web Encryption (JWE) encryption and decryption +- Json Web Tokens (JWT) signing, verification and validation +- 100% type annotated, verified with `mypy --strict` +- nearly 100% code coverage +- Relies on [cryptography](https://cryptography.io) for all cryptographic operations +- Relies on [BinaPy](https://guillp.github.io/binapy/) for binary data manipulations + +### Supported Token Types + + +| Token Type | Support | +| ------------------------- | -------------------------------------------------------- | +| Json Web Signature (JWS) | ☑ Compact
☑ JSON Flat
☑ JSON General
| +| Json Web Encryption (JWE) | ☑ Compact
☐ JSON Flat
☐ JSON General
| +| Json Web Tokens (JWT) | ☑ Signed
☑ Signed and Encrypted | + +### Supported Signature algorithms + + +| Signature Alg | Description | Key Type | Reference | Note | +|-----------------|--------------------------------------------------|----------| ---------------------------------- | ------------------------------ | +| `HS256` | HMAC using SHA-256 | `oct` | [RFC7518, Section 3.2] | | +| `HS384` | HMAC using SHA-384 | `oct` | [RFC7518, Section 3.2] | | +| `HS512` | HMAC using SHA-512 | `oct` | [RFC7518, Section 3.2] | | +| `RS256` | RSASSA-PKCS1-v1_5 using SHA-256 | `RSA` | [RFC7518, Section 3.3] | | +| `RS384` | RSASSA-PKCS1-v1_5 using SHA-384 | `RSA` | [RFC7518, Section 3.3] | | +| `RS512` | RSASSA-PKCS1-v1_5 using SHA-512 | `RSA` | [RFC7518, Section 3.3] | | +| `PS256` | RSASSA-PSS using SHA-256 and MGF1 with SHA-256 | `RSA` | [RFC7518, Section 3.5] | | +| `PS384` | RSASSA-PSS using SHA-384 and MGF1 with SHA-384 | `RSA` | [RFC7518, Section 3.5] | | +| `PS512` | RSASSA-PSS using SHA-512 and MGF1 with SHA-512 | `RSA` | [RFC7518, Section 3.5] | | +| `ES256` | ECDSA using P-256 and SHA-256 | `EC` | [RFC7518, Section 3.4] | | +| `ES384` | ECDSA using P-384 and SHA-384 | `EC` | [RFC7518, Section 3.4] | | +| `ES512` | ECDSA using P-521 and SHA-512 | `EC` | [RFC7518, Section 3.4] | | +| `ES256K` | ECDSA using secp256k1 curve and SHA-256 | `EC` | [RFC8812, Section 3.2] | | +| `EdDSA` | EdDSA signature algorithms | `OKP` | [RFC8037, Section 3.1] | Ed2219 and Ed448 are supported | +| `HS1` | HMAC using SHA-1 | `oct` | https://www.w3.org/TR/WebCryptoAPI | Validation Only | +| `RS1` | RSASSA-PKCS1-v1_5 with SHA-1 | `RSA` | https://www.w3.org/TR/WebCryptoAPI | Validation Only | +| `none` | No digital signature or MAC performed | | [RFC7518, Section 3.6] | Not usable by mistake | + +### Supported Encryption algorithms + + +| Signature Alg | Description | Reference | +|-----------------|-------------------------------------------------------------|--------------------------| +| `A128CBC-HS256` | AES_128_CBC_HMAC_SHA_256 authenticated encryption algorithm | [RFC7518, Section 5.2.3] | +| `A192CBC-HS384` | AES_192_CBC_HMAC_SHA_384 authenticated encryption algorithm | [RFC7518, Section 5.2.4] | +| `A256CBC-HS512` | AES_256_CBC_HMAC_SHA_512 authenticated encryption algorithm | [RFC7518, Section 5.2.5] | +| `A128GCM` | AES GCM using 128-bit key | [RFC7518, Section 5.3] | +| `A192GCM` | AES GCM using 192-bit key | [RFC7518, Section 5.3] | +| `A256GCM` | AES GCM using 256-bit key | [RFC7518, Section 5.3] | + + +### Supported Key Management algorithms + + +| Signature Alg | Description | Key Type | Reference | Note | +| ------------------ | ---------------------------------------------- |---------------| ---------------------------------- | ----------- | +| `RSA1_5` | RSAES-PKCS1-v1_5 | `RSA` | [RFC7518, Section 4.2] | Unwrap Only | +| `RSA-OAEP` | RSAES OAEP using default parameters | `RSA` | [RFC7518, Section 4.3] | | +| `RSA-OAEP-256` | RSAES OAEP using SHA-256 and MGF1 with SHA-256 | `RSA` | [RFC7518, Section 4.3] | | +| `RSA-OAEP-384` | RSA-OAEP using SHA-384 and MGF1 with SHA-384 | `RSA` | https://www.w3.org/TR/WebCryptoAPI | | +| `RSA-OAEP-512` | RSA-OAEP using SHA-512 and MGF1 with SHA-512 | `RSA` | https://www.w3.org/TR/WebCryptoAPI | | +| `A128KW` | AES Key Wrap using 128-bit key | `oct` | [RFC7518, Section 4.4] | | +| `A192KW` | AES Key Wrap using 192-bit key | `oct` | [RFC7518, Section 4.4] | | +| `A256KW` | AES Key Wrap using 256-bit key | `oct` | [RFC7518, Section 4.4] | | +| `A128GCMKW` | Key wrapping with AES GCM using 128-bit key | `oct` | [RFC7518, Section 4.7] | | +| `A192GCMKW` | Key wrapping with AES GCM using 192-bit key | `oct` | [RFC7518, Section 4.7] | | +| `A256GCMKW` | Key wrapping with AES GCM using 256-bit key | `oct` | [RFC7518, Section 4.7] | | +| `dir` | Direct use of a shared symmetric key | `oct` | [RFC7518, Section 4.5] | | +| `ECDH-ES` | ECDH-ES using Concat KDF | `EC` | [RFC7518, Section 4.6] | | +| `ECDH-ES+A128KW` | ECDH-ES using Concat KDF and "A128KW" wrapping | `EC` | [RFC7518, Section 4.6] | | +| `ECDH-ES+A192KW` | ECDH-ES using Concat KDF and "A192KW" wrapping | `EC` | [RFC7518, Section 4.6] | | +| `ECDH-ES+A256KW` | ECDH-ES using Concat KDF and "A256KW" wrapping | `EC` | [RFC7518, Section 4.6] | | +| `PBES2-HS256+A128KW` | PBES2 with HMAC SHA-256 and "A128KW" wrapping | `password` | [RFC7518, Section 4.8] | | +| `PBES2-HS384+A192KW` | PBES2 with HMAC SHA-384 and "A192KW" wrapping | `password` | [RFC7518, Section 4.8] | | +| `PBES2-HS512+A256KW` | PBES2 with HMAC SHA-512 and "A256KW" wrapping | `password` | [RFC7518, Section 4.8] | | + + + +### Supported Elliptic Curves + + +| Curve | Description | Key Type | Usage | Reference | +|-------------|---------------------------------------|----------| --------------------- | -------------------------- | +| `P-256` | P-256 Curve | `EC` | signature, encryption | [RFC7518, Section 6.2.1.1] | +| `P-384` | P-384 Curve | `EC` | signature, encryption | [RFC7518, Section 6.2.1.1] | +| `P-521` | P-521 Curve | `EC` | signature, encryption | [RFC7518, Section 6.2.1.1] | +| `secp256k1` | SECG secp256k1 curve | `EC` | signature, encryption | [RFC8812, Section 3.1] | +| `Ed25519` | Ed25519 signature algorithm key pairs | `OKP` | signature | [RFC8037, Section 3.1] | +| `Ed448` | Ed448 signature algorithm key pairs | `OKP` | signature | [RFC8037, Section 3.1] | +| `X25519` | X25519 function key pairs | `OKP` | encryption | [RFC8037, Section 3.2] | +| `X448` | X448 function key pairs | `OKP` | encryption | [RFC8037, Section 3.2] | + +## Why a new lib? + +There are already multiple modules implementing JOSE and Json Web Crypto related specifications in Python. However, I +have been dissatisfied by all of them so far, so I decided to come up with my own module. + +- [PyJWT](https://pyjwt.readthedocs.io) +- [JWCrypto](https://jwcrypto.readthedocs.io/) +- [Python-JOSE](https://python-jose.readthedocs.io/) +- [AuthLib](https://docs.authlib.org/en/latest/jose/) + +Not to say that those are _bad_ libs (I actually use `jwcrypto` myself for `jwskate` unit tests), but they either don't +support some important features, lack documentation, or more generally have APIs that don't feel easy-enough, Pythonic-enough +to use. + +## Design + +### JWK are UserDicts + +JWK are specified as JSON objects, which are parsed as `dict` in Python. The `Jwk` class in `jwskate` is actually a +`UserDict` subclass, which is very similar to a standard `dict`. So you can use it exactly like you would use a `dict`: +you can access its members, dump it back as JSON, etc. The same is true for Signed or Encrypted Json Web tokens in JSON +format. However, you cannot change the key cryptographic materials, since that would lead to unusable keys. + +### JWA Wrappers + +You can use `cryptography` to do the cryptographic operations that are described in +[JWA](https://www.rfc-editor.org/info/rfc7518), but since `cryptography` is a general purpose library, its usage is not +straightforward and gives you plenty of options to carefully select and combine, leaving room for mistakes, errors and +confusion. It has also a quite inconsistent API to handle the different type of keys and algorithms. To work around +this, `jwskate` comes with a set of consistent wrappers that implement the exact JWA specifications, with minimum risk +of mistakes. + +### Safe Signature Verification + +As advised in [JWT Best Practices][rfc8725] $3.1: + +For every signature verification method in `jwskate`, the expected signature(s) algorithm(s) must be specified. That is +to avoid a security flaw where your application accepts tokens with a weaker encryption scheme than what your security +policy mandates; or even worse, where it accepts unsigned tokens, or tokens that are symmetrically signed with an +improperly used public key, leaving your application exposed to exploitation by attackers. + +To specify which signature algorithms are accepted, each signature verification method accepts, in order of preference: + +- an `alg` parameter which contains the expected algorithm, or an `algs` parameter which contains a list of acceptable + algorithms +- the `alg` parameter from the signature verification `Jwk`, if present. This `alg` is the algorithm intended for use + with that key. + +Note that you cannot use `alg` and `algs` at the same time. If your `Jwk` contains an `alg` parameter, and you provide +an `alg` or `algs` which does not match that value, a `Warning` will be emitted. + +## TODO + +- Complete/enhance/proof-read documentation +- Better exceptions (create dedicated exception classes, better messages, etc.) +- Support for JWE in JSON format +- Better tests +- Support for Selective-Disclosure JWT + +## Credits + +All cryptographic operations are handled by [cryptography](https://cryptography.io). + +[rfc7515]: https://www.rfc-editor.org/rfc/rfc7515.html +[rfc7516]: https://www.rfc-editor.org/rfc/rfc7516.html +[rfc7517]: https://www.rfc-editor.org/rfc/rfc7517.html +[rfc7518]: https://www.rfc-editor.org/rfc/rfc7518.html +[rfc7518, section 3.2]: https://www.rfc-editor.org/rfc/rfc7518.html#section-3.2 +[rfc7518, section 3.3]: https://www.rfc-editor.org/rfc/rfc7518.html#section-3.3 +[rfc7518, section 3.4]: https://www.rfc-editor.org/rfc/rfc7518.html#section-3.4 +[rfc7518, section 3.5]: https://www.rfc-editor.org/rfc/rfc7518.html#section-3.5 +[rfc7518, section 3.6]: https://www.rfc-editor.org/rfc/rfc7518.html#section-3.6 +[rfc7518, section 4.2]: https://www.rfc-editor.org/rfc/rfc7518.html#section-4.2 +[rfc7518, section 4.3]: https://www.rfc-editor.org/rfc/rfc7518.html#section-4.3 +[rfc7518, section 4.4]: https://www.rfc-editor.org/rfc/rfc7518.html#section-4.4 +[rfc7518, section 4.5]: https://www.rfc-editor.org/rfc/rfc7518.html#section-4.5 +[rfc7518, section 4.6]: https://www.rfc-editor.org/rfc/rfc7518.html#section-4.6 +[rfc7518, section 4.7]: https://www.rfc-editor.org/rfc/rfc7518.html#section-4.7 +[rfc7518, section 4.8]: https://www.rfc-editor.org/rfc/rfc7518.html#section-4.8 +[rfc7518, section 5.2.3]: https://www.rfc-editor.org/rfc/rfc7518.html#section-5.2.3 +[rfc7518, section 5.2.4]: https://www.rfc-editor.org/rfc/rfc7518.html#section-5.2.4 +[rfc7518, section 5.2.5]: https://www.rfc-editor.org/rfc/rfc7518.html#section-5.2.5 +[rfc7518, section 5.3]: https://www.rfc-editor.org/rfc/rfc7518.html#section-5.3 +[rfc7518, section 6.2.1.1]: https://www.rfc-editor.org/rfc/rfc7518.html#section-6.2.1.1 +[rfc7519]: https://www.rfc-editor.org/rfc/rfc7519.html +[rfc7638]: https://www.rfc-editor.org/rfc/rfc7638.html +[rfc8037]: https://www.rfc-editor.org/rfc/rfc8037.html +[rfc8037, section 3.1]: https://www.rfc-editor.org/rfc/rfc8037.html#section-3.1 +[rfc8037, section 3.2]: https://www.rfc-editor.org/rfc/rfc8037.html#section-3.2 +[rfc8725]: https://www.rfc-editor.org/rfc/rfc8725 +[rfc8812, section 3.1]: https://www.rfc-editor.org/rfc/rfc8812.html#section-3.1 +[rfc8812, section 3.2]: https://www.rfc-editor.org/rfc/rfc8812.html#name-ecdsa-signature-with-secp25 +[rfc9278]: https://www.rfc-editor.org/rfc/rfc9278.html diff --git a/jwskate/enums.py b/jwskate/enums.py index 99a9c5a..a4a18d0 100644 --- a/jwskate/enums.py +++ b/jwskate/enums.py @@ -78,7 +78,7 @@ class KeyManagementAlgs: A128GCMKW = "A128GCMKW" A192GCMKW = "A192GCMKW" A256GCMKW = "A256GCMKW" - dir = "dir" # noqa: A003 + dir = "dir" PBES2_HS256_A128KW = "PBES2-HS256+A128KW" PBES2_HS384_A192KW = "PBES2-HS384+A192KW" diff --git a/jwskate/jwa/encryption/aesgcm.py b/jwskate/jwa/encryption/aesgcm.py index 6612aa7..00a6048 100644 --- a/jwskate/jwa/encryption/aesgcm.py +++ b/jwskate/jwa/encryption/aesgcm.py @@ -50,7 +50,7 @@ def encrypt( if not isinstance(plaintext, bytes): plaintext = bytes(plaintext) ciphertext_with_tag = BinaPy(aead.AESGCM(self.key).encrypt(iv, plaintext, aad)) - ciphertext, tag = ciphertext_with_tag.cut_at(-self.tag_size) + ciphertext, tag = ciphertext_with_tag.split_at(-self.tag_size) return ciphertext, tag def decrypt( diff --git a/jwskate/jwa/signature/ec.py b/jwskate/jwa/signature/ec.py index 44a3b3b..b493a88 100644 --- a/jwskate/jwa/signature/ec.py +++ b/jwskate/jwa/signature/ec.py @@ -44,7 +44,9 @@ def sign(self, data: bytes | SupportsBytes) -> BinaPy: with self.private_key_required() as key: dss_sig = key.sign(data, ec.ECDSA(self.hashing_alg)) r, s = asymmetric.utils.decode_dss_signature(dss_sig) - return BinaPy.from_int(r, self.curve.coordinate_size) + BinaPy.from_int(s, self.curve.coordinate_size) + return BinaPy.from_int(r, length=self.curve.coordinate_size) + BinaPy.from_int( + s, length=self.curve.coordinate_size + ) @override def verify(self, data: bytes | SupportsBytes, signature: bytes | SupportsBytes) -> bool: diff --git a/jwskate/jwe/compact.py b/jwskate/jwe/compact.py index f98cb83..bb804ee 100644 --- a/jwskate/jwe/compact.py +++ b/jwskate/jwe/compact.py @@ -140,11 +140,11 @@ def enc(self) -> str: def encrypt( cls, plaintext: bytes | SupportsBytes, - key: Jwk | dict[str, Any] | Any, + key: Jwk | Mapping[str, Any] | Any, *, enc: str, alg: str | None = None, - extra_headers: dict[str, Any] | None = None, + extra_headers: Mapping[str, Any] | None = None, cek: bytes | None = None, iv: bytes | None = None, epk: Jwk | None = None, @@ -188,7 +188,7 @@ def encrypt( def unwrap_cek( self, - key_or_password: Jwk | dict[str, Any] | bytes | str, + key_or_password: Jwk | Mapping[str, Any] | bytes | str, alg: str | None = None, algs: Iterable[str] | None = None, ) -> Jwk: @@ -220,7 +220,7 @@ def unwrap_cek( def decrypt( self, - key: Jwk | dict[str, Any] | Any, + key: Jwk | Mapping[str, Any] | Any, *, alg: str | None = None, algs: Iterable[str] | None = None, @@ -249,7 +249,7 @@ def decrypt( def decrypt_jwt( self, - key: Jwk | dict[str, Any] | Any, + key: Jwk | Mapping[str, Any] | Any, *, alg: str | None = None, algs: Iterable[str] | None = None, diff --git a/jwskate/jwk/base.py b/jwskate/jwk/base.py index 987dbfb..fd23a7d 100644 --- a/jwskate/jwk/base.py +++ b/jwskate/jwk/base.py @@ -12,6 +12,7 @@ from __future__ import annotations import warnings +from copy import copy from dataclasses import dataclass from typing import TYPE_CHECKING, Any, ClassVar, Iterable, Mapping, SupportsBytes @@ -154,7 +155,7 @@ def generate_for_kty(cls, kty: str, **kwargs: Any) -> Jwk: "shake256": "shake256", } - def __new__(cls, key: Jwk | dict[str, Any] | Any, **kwargs: Any) -> Jwk: + def __new__(cls, key: Jwk | Mapping[str, Any] | Any, **kwargs: Any) -> Jwk: """Overridden `__new__` to make the Jwk constructor smarter. The `Jwk` constructor will accept: @@ -171,7 +172,7 @@ def __new__(cls, key: Jwk | dict[str, Any] | Any, **kwargs: Any) -> Jwk: if cls == Jwk: if isinstance(key, Jwk): return cls.from_cryptography_key(key.cryptography_key, **kwargs) - if isinstance(key, dict): + if isinstance(key, Mapping): kty: str | None = key.get("kty") if kty is None: msg = "A Json Web Key must have a Key Type (kty)" @@ -188,9 +189,9 @@ def __new__(cls, key: Jwk | dict[str, Any] | Any, **kwargs: Any) -> Jwk: return cls.from_json(key) else: return cls.from_cryptography_key(key, **kwargs) - return super().__new__(cls, key, **kwargs) + return super().__new__(cls) - def __init__(self, params: dict[str, Any] | Any, *, include_kid_thumbprint: bool = False): + def __init__(self, params: Mapping[str, Any] | Any, *, include_kid_thumbprint: bool = False): if isinstance(params, dict): # this is to avoid double init due to the __new__ above super().__init__({key: val for key, val in params.items() if val is not None}) self._validate() @@ -275,7 +276,8 @@ def __setitem__(self, key: str, value: Any) -> None: RuntimeError: when trying to modify cryptographic attributes """ - if key in self.PARAMS: + # don't allow modifying private attributes after the key has been initialized + if key in self.PARAMS and hasattr(self, "cryptography_key"): msg = "JWK key attributes cannot be modified." raise RuntimeError(msg) super().__setitem__(key, value) @@ -305,12 +307,18 @@ def alg(self) -> str | None: return alg @property - def kid(self) -> str | None: - """Return the JWK key ID (kid), if present.""" + def kid(self) -> str: + """Return the JWK key ID (kid). + + If the kid is not explicitly set, the RFC7638 key thumbprint is returned. + + """ kid = self.get("kid") if kid is not None and not isinstance(kid, str): # pragma: no branch msg = f"invalid kid type {type(kid)}" raise TypeError(msg, kid) + if kid is None: + return self.thumbprint() return kid @property @@ -1220,7 +1228,7 @@ def copy(self) -> Jwk: a copy of this key, with the same value """ - return Jwk(super().copy()) + return Jwk(copy(self.data)) def with_kid_thumbprint(self, *, force: bool = False) -> Jwk: """Include the JWK thumbprint as `kid`. diff --git a/jwskate/jwk/ec.py b/jwskate/jwk/ec.py index c476c86..4e8a962 100644 --- a/jwskate/jwk/ec.py +++ b/jwskate/jwk/ec.py @@ -150,9 +150,9 @@ def private(cls, *, crv: str, x: int, y: int, d: int, **params: Any) -> ECJwk: dict( kty=cls.KTY, crv=crv, - x=BinaPy.from_int(x, coord_size).to("b64u").ascii(), - y=BinaPy.from_int(y, coord_size).to("b64u").ascii(), - d=BinaPy.from_int(d, coord_size).to("b64u").ascii(), + x=BinaPy.from_int(x, length=coord_size).to("b64u").ascii(), + y=BinaPy.from_int(y, length=coord_size).to("b64u").ascii(), + d=BinaPy.from_int(d, length=coord_size).to("b64u").ascii(), **{k: v for k, v in params.items() if v is not None}, ) ) @@ -218,12 +218,12 @@ def from_cryptography_key(cls, cryptography_key: Any, **kwargs: Any) -> ECJwk: msg = f"Unsupported Curve {cryptography_key.curve.name}" raise NotImplementedError(msg) - x = BinaPy.from_int(public_numbers.x, crv.coordinate_size).to("b64u").ascii() - y = BinaPy.from_int(public_numbers.y, crv.coordinate_size).to("b64u").ascii() + x = BinaPy.from_int(public_numbers.x, length=crv.coordinate_size).to("b64u").ascii() + y = BinaPy.from_int(public_numbers.y, length=crv.coordinate_size).to("b64u").ascii() parameters = {"kty": KeyTypes.EC, "crv": crv.name, "x": x, "y": y} if isinstance(cryptography_key, ec.EllipticCurvePrivateKey): pn = cryptography_key.private_numbers() # type: ignore[attr-defined] - d = BinaPy.from_int(pn.private_value, crv.coordinate_size).to("b64u").ascii() + d = BinaPy.from_int(pn.private_value, length=crv.coordinate_size).to("b64u").ascii() parameters["d"] = d return cls(parameters) diff --git a/jwskate/jwk/jwks.py b/jwskate/jwk/jwks.py index 7b760c9..a3fc22f 100644 --- a/jwskate/jwk/jwks.py +++ b/jwskate/jwk/jwks.py @@ -2,7 +2,9 @@ from __future__ import annotations -from typing import Any, Iterable +from typing import Any, Iterable, Mapping + +from typing_extensions import override from jwskate.token import BaseJsonDict @@ -17,8 +19,8 @@ class JwkSet(BaseJsonDict): methods to get the keys, add or remove keys, and verify signatures using keys from this set. - - a `dict` from the parsed JSON object representing this JwkSet (in paramter `jwks`) - - a list of `Jwk` (in parameter `keys` + - a `dict` from the parsed JSON object representing this JwkSet (in parameter `jwks`) + - a list of `Jwk` (in parameter `keys`) - nothing, to initialize an empty JwkSet Args: @@ -29,21 +31,23 @@ class JwkSet(BaseJsonDict): def __init__( self, - jwks: dict[str, Any] | None = None, - keys: Iterable[Jwk | dict[str, Any]] | None = None, + jwks: Mapping[str, Any] | None = None, + keys: Iterable[Jwk | Mapping[str, Any]] | None = None, ): - if jwks is None and keys is None: - keys = [] - - if jwks is not None: - keys = jwks.pop("keys", []) - super().__init__(jwks) # init the dict with all the dict content that is not keys + super().__init__({k: v for k, v in jwks.items() if k != "keys"} if jwks else {}) + if keys is None and jwks is not None and "keys" in jwks: + keys = jwks.get("keys") + if keys: + for key in keys: + self.add_jwk(key) + + @override + def __setitem__(self, name: str, value: Any) -> None: + if name == "keys": + for key in value: + self.add_jwk(key) else: - super().__init__() - - if keys is not None: - for jwk in keys: - self.add_jwk(jwk) + super().__setitem__(name, value) @property def jwks(self) -> list[Jwk]: @@ -53,7 +57,7 @@ def jwks(self) -> list[Jwk]: a list of `Jwk` """ - return self.get("keys", []) # type: ignore[no-any-return] + return self.get("keys", []) def get_jwk_by_kid(self, kid: str) -> Jwk: """Return a Jwk from this JwkSet, based on its kid. @@ -84,35 +88,23 @@ def __len__(self) -> int: def add_jwk( self, - key: Jwk | dict[str, Any] | Any, - kid: str | None = None, - use: str | None = None, + key: Jwk | Mapping[str, Any] | Any, ) -> str: """Add a Jwk in this JwkSet. Args: key: the Jwk to add (either a `Jwk` instance, or a dict containing the Jwk parameters) - kid: the kid to use, if `jwk` doesn't contain one - use: the defined use for the added Jwk Returns: - the kid from the added Jwk (it may be generated if no kid is provided) + the key ID. It will be generated if missing from the given Jwk. """ - key = to_jwk(key) - - self.setdefault("keys", []) + key = to_jwk(key).with_kid_thumbprint() - kid = key.get("kid", kid) - if not kid: - kid = key.thumbprint() - key["kid"] = kid - use = key.get("use", use) - if use: - key["use"] = use - self.jwks.append(key) + self.data.setdefault("keys", []) + self.data["keys"].append(key) - return kid + return key.kid def remove_jwk(self, kid: str) -> None: """Remove a Jwk from this JwkSet, based on a `kid`. @@ -198,7 +190,7 @@ def verify( jwk = self.get_jwk_by_kid(kid) return jwk.verify(data, signature, alg=alg, algs=algs) - # otherwise, try all keys which support the given alg(s) + # otherwise, try all keys that support the given alg(s) if algs is None: if alg is not None: algs = (alg,) diff --git a/jwskate/jws/compact.py b/jwskate/jws/compact.py index 2dc46b2..9ba7847 100644 --- a/jwskate/jws/compact.py +++ b/jwskate/jws/compact.py @@ -3,7 +3,7 @@ from __future__ import annotations from functools import cached_property -from typing import TYPE_CHECKING, Any, Iterable, SupportsBytes +from typing import TYPE_CHECKING, Any, Iterable, Mapping, SupportsBytes from binapy import BinaPy from typing_extensions import Self @@ -62,9 +62,9 @@ def __init__(self, value: bytes | str, max_size: int = 16 * 1024): def sign( cls, payload: bytes | SupportsBytes, - key: Jwk | dict[str, Any] | Any, + key: Jwk | Mapping[str, Any] | Any, alg: str | None = None, - extra_headers: dict[str, Any] | None = None, + extra_headers: Mapping[str, Any] | None = None, ) -> JwsCompact: """Sign a payload and returns the resulting JwsCompact. @@ -132,7 +132,7 @@ def signed_part(self) -> bytes: def verify_signature( self, - key: Jwk | dict[str, Any] | Any, + key: Jwk | Mapping[str, Any] | Any, *, alg: str | None = None, algs: Iterable[str] | None = None, @@ -153,7 +153,7 @@ def verify_signature( def verify( self, - key: Jwk | dict[str, Any] | Any, + key: Jwk | Mapping[str, Any] | Any, *, alg: str | None = None, algs: Iterable[str] | None = None, diff --git a/jwskate/jws/json.py b/jwskate/jws/json.py index 69ce67d..a0cf063 100644 --- a/jwskate/jws/json.py +++ b/jwskate/jws/json.py @@ -52,7 +52,7 @@ def jws_signature(self) -> JwsSignature: def sign( cls, payload: bytes, - key: Jwk | dict[str, Any] | Any, + key: Jwk | Mapping[str, Any] | Any, alg: str | None = None, extra_protected_headers: Mapping[str, Any] | None = None, header: Any | None = None, @@ -115,7 +115,7 @@ def compact(self) -> JwsCompact: def verify_signature( self, - key: Jwk | dict[str, Any] | Any, + key: Jwk | Mapping[str, Any] | Any, *, alg: str | None = None, algs: Iterable[str] | None = None, @@ -218,7 +218,7 @@ def signatures(self) -> list[JwsSignature]: def add_signature( self, - key: Jwk | dict[str, Any] | Any, + key: Jwk | Mapping[str, Any] | Any, alg: str | None = None, extra_protected_headers: Mapping[str, Any] | None = None, header: Mapping[str, Any] | None = None, @@ -306,7 +306,7 @@ def flatten( def verify_signature( self, - key: Jwk | dict[str, Any] | Any, + key: Jwk | Mapping[str, Any] | Any, *, alg: str | None = None, algs: Iterable[str] | None = None, diff --git a/jwskate/jws/signature.py b/jwskate/jws/signature.py index e213e28..73fb000 100644 --- a/jwskate/jws/signature.py +++ b/jwskate/jws/signature.py @@ -113,7 +113,7 @@ def signature(self) -> bytes: def sign( cls: type[S], payload: bytes, - key: Jwk | dict[str, Any] | Any, + key: Jwk | Mapping[str, Any] | Any, alg: str | None = None, extra_protected_headers: Mapping[str, Any] | None = None, header: Any | None = None, @@ -145,7 +145,7 @@ def sign( return cls.from_parts(protected=headers, signature=signature, header=header, **kwargs) @classmethod - def assemble_signed_part(cls, headers: dict[str, Any], payload: bytes | str) -> bytes: + def assemble_signed_part(cls, headers: Mapping[str, Any], payload: bytes | str) -> bytes: """Assemble the protected header and payload to sign, as specified in. [RFC7515 @@ -169,7 +169,7 @@ def assemble_signed_part(cls, headers: dict[str, Any], payload: bytes | str) -> def verify( self, payload: bytes, - key: Jwk | dict[str, Any] | Any, + key: Jwk | Mapping[str, Any] | Any, *, alg: str | None = None, algs: Iterable[str] | None = None, diff --git a/jwskate/jwt/base.py b/jwskate/jwt/base.py index 554987e..d23a6e1 100644 --- a/jwskate/jwt/base.py +++ b/jwskate/jwt/base.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import datetime, timezone -from typing import TYPE_CHECKING, Any, Iterable +from typing import TYPE_CHECKING, Any, Iterable, Mapping from binapy import BinaPy @@ -50,12 +50,12 @@ def __new__(cls, value: bytes | str, max_size: int = 16 * 1024) -> SignedJwt | J @classmethod def sign( cls, - claims: dict[str, Any], - key: Jwk | dict[str, Any] | Any, + claims: Mapping[str, Any], + key: Jwk | Mapping[str, Any] | Any, *, alg: str | None = None, typ: str | None = "JWT", - extra_headers: dict[str, Any] | None = None, + extra_headers: Mapping[str, Any] | None = None, ) -> SignedJwt: """Sign a JSON payload with a private key and return the resulting `SignedJwt`. @@ -93,9 +93,9 @@ def sign( @classmethod def sign_arbitrary( cls, - claims: dict[str, Any], - headers: dict[str, Any], - key: Jwk | dict[str, Any] | Any, + claims: Mapping[str, Any], + headers: Mapping[str, Any], + key: Jwk | Mapping[str, Any] | Any, *, alg: str | None = None, ) -> SignedJwt: @@ -131,10 +131,10 @@ def sign_arbitrary( @classmethod def unprotected( cls, - claims: dict[str, Any], + claims: Mapping[str, Any], *, typ: str | None = "JWT", - extra_headers: dict[str, Any] | None = None, + extra_headers: Mapping[str, Any] | None = None, ) -> SignedJwt: """Generate a JWT that is not signed and not encrypted (with alg=none). @@ -162,15 +162,15 @@ def unprotected( @classmethod def sign_and_encrypt( cls, - claims: dict[str, Any], - sign_key: Jwk | dict[str, Any] | Any, - enc_key: Jwk | dict[str, Any] | Any, + claims: Mapping[str, Any], + sign_key: Jwk | Mapping[str, Any] | Any, + enc_key: Jwk | Mapping[str, Any] | Any, enc: str, *, sign_alg: str | None = None, enc_alg: str | None = None, - sign_extra_headers: dict[str, Any] | None = None, - enc_extra_headers: dict[str, Any] | None = None, + sign_extra_headers: Mapping[str, Any] | None = None, + enc_extra_headers: Mapping[str, Any] | None = None, ) -> JweCompact: """Sign a JWT, then encrypt it as JWE payload. @@ -197,7 +197,7 @@ def sign_and_encrypt( ) @classmethod - def decrypt_nested_jwt(cls, jwe: str | JweCompact, key: Jwk | dict[str, Any] | Any) -> SignedJwt: + def decrypt_nested_jwt(cls, jwe: str | JweCompact, key: Jwk | Mapping[str, Any] | Any) -> SignedJwt: """Decrypt a JWE that contains a nested signed JWT. It will return a [Jwt] instance for the inner JWT. @@ -221,8 +221,8 @@ def decrypt_nested_jwt(cls, jwe: str | JweCompact, key: Jwk | dict[str, Any] | A def decrypt_and_verify( cls, jwt: str | JweCompact, - enc_key: Jwk | dict[str, Any] | Any, - sig_key: Jwk | dict[str, Any] | Any, + enc_key: Jwk | Mapping[str, Any] | Any, + sig_key: Jwk | Mapping[str, Any] | Any, sig_alg: str | None = None, sig_algs: Iterable[str] | None = None, ) -> SignedJwt: diff --git a/jwskate/jwt/signed.py b/jwskate/jwt/signed.py index f48a591..6ee4fcf 100644 --- a/jwskate/jwt/signed.py +++ b/jwskate/jwt/signed.py @@ -4,7 +4,7 @@ from datetime import datetime, timedelta, timezone from functools import cached_property -from typing import Any, Iterable +from typing import Any, Iterable, Mapping from binapy import BinaPy from typing_extensions import Self @@ -80,7 +80,7 @@ def signed_part(self) -> bytes: def verify_signature( self, - key: Jwk | dict[str, Any] | Any, + key: Jwk | Mapping[str, Any] | Any, alg: str | None = None, algs: Iterable[str] | None = None, ) -> bool: @@ -351,7 +351,7 @@ def __bytes__(self) -> bytes: def validate( self, - key: Jwk | dict[str, Any] | Any, + key: Jwk | Mapping[str, Any] | Any, *, alg: str | None = None, algs: Iterable[str] | None = None, @@ -420,7 +420,7 @@ def validate( raise InvalidClaim(key, f"unexpected value for claim {key}", claim) def encrypt( - self, key: Any, enc: str, alg: str | None = None, extra_headers: dict[str, Any] | None = None + self, key: Any, enc: str, alg: str | None = None, extra_headers: Mapping[str, Any] | None = None ) -> JweCompact: """Encrypt this JWT into a JWE. @@ -433,7 +433,7 @@ def encrypt( extra_headers: additional headers to include in the outer JWE. """ - extra_headers = extra_headers or {} + extra_headers = dict(extra_headers) if extra_headers else {} extra_headers.setdefault("cty", "JWT") jwe = JweCompact.encrypt(self, key, enc=enc, alg=alg, extra_headers=extra_headers) diff --git a/jwskate/jwt/signer.py b/jwskate/jwt/signer.py index a53b46d..051756e 100644 --- a/jwskate/jwt/signer.py +++ b/jwskate/jwt/signer.py @@ -27,7 +27,7 @@ from __future__ import annotations import uuid -from typing import Any, Callable, Iterable +from typing import Any, Callable, Iterable, Mapping from jwskate.jwk import Jwk @@ -86,8 +86,8 @@ def sign( *, subject: str | None = None, audience: str | Iterable[str] | None = None, - extra_claims: dict[str, Any] | None = None, - extra_headers: dict[str, Any] | None = None, + extra_claims: Mapping[str, Any] | None = None, + extra_headers: Mapping[str, Any] | None = None, lifetime: int | None = None, leeway: int | None = None, ) -> SignedJwt: diff --git a/jwskate/jwt/verifier.py b/jwskate/jwt/verifier.py index 06d57d8..5d8498b 100644 --- a/jwskate/jwt/verifier.py +++ b/jwskate/jwt/verifier.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Any, Callable, Iterable +from typing import Any, Callable, Iterable, Mapping from jwskate import InvalidSignature, Jwk, JwkSet @@ -45,7 +45,7 @@ class JwtVerifier: def __init__( self, - jwkset: JwkSet | Jwk | dict[str, Any], + jwkset: JwkSet | Jwk | Mapping[str, Any], *, issuer: str | None, audience: str | None = None, diff --git a/jwskate/token.py b/jwskate/token.py index 89556e6..7377e7c 100644 --- a/jwskate/token.py +++ b/jwskate/token.py @@ -2,9 +2,13 @@ from __future__ import annotations -import json +import sys +from collections import UserDict from functools import cached_property -from typing import Any, Dict, TypeVar +from typing import Any + +from binapy import BinaPy +from typing_extensions import Self class BaseCompactToken: @@ -141,14 +145,17 @@ def __bytes__(self) -> bytes: return self.value -D = TypeVar("D", bound="BaseJsonDict") +if sys.version_info[:2] > (3, 8): + BaseUserDict = UserDict[str, Any] +else: + BaseUserDict = UserDict -class BaseJsonDict(Dict[str, Any]): +class BaseJsonDict(BaseUserDict): """Base class Jwk and tokens in JSON representation.""" @classmethod - def from_json(cls: type[D], j: str) -> D: + def from_json(cls, j: str) -> Self: """Initialize an object based on a string containing a JSON representation. Args: @@ -158,17 +165,17 @@ def from_json(cls: type[D], j: str) -> D: the resulting object """ - return cls(json.loads(j)) + return cls(BinaPy(j).parse_from("json")) - def to_json(self, *args: Any, **kwargs: Any) -> str: + def to_json(self, *, compact: bool = True, **kwargs: Any) -> str: """Serialize the current object into a JSON representation. Args: - *args: additional args for json.dumps() + compact: if True, don't include whitespaces or newlines in the result **kwargs: additional kwargs for json.dumps() Returns: a JSON representation of the current object """ - return json.dumps(self, *args, **kwargs) + return BinaPy.serialize_to("json", self, compact=compact, **kwargs).decode() diff --git a/pyproject.toml b/pyproject.toml index ff44f7c..721c40b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ classifiers = [ dependencies = [ "cryptography>=3.4", "typing-extensions>=4.3", - "binapy>=0.7", + "binapy>=0.8", ] [project.urls] diff --git a/tests/test_jwk/test_jwk.py b/tests/test_jwk/test_jwk.py index 16a0e8f..99ae45a 100644 --- a/tests/test_jwk/test_jwk.py +++ b/tests/test_jwk/test_jwk.py @@ -115,9 +115,24 @@ def test_json() -> None: jwk = Jwk.from_json(j) assert ( jwk.public_jwk().as_jwks().to_json() - == '{"keys": [{"kty": "RSA", "kid": "client_assertion_key", "n":' - ' "5nHd3aefARenRFQn-wVrjBLS-5A0uUiHWfOUt8EpjwE3wADAvVPKLbvRQJfugyrn_RpnLqqZFkwYrVD_u1Uzl9J17XJG75jGjCf-gVs1t9FPpgEsJGYK4RK2_f40AxAc6hKomB9q6_dqIxChDxVCrIrrWd9kRk0T86d8Ade3J4f_iMbremm3woSwI6QD056DkRtAD_v2PZQbUBgSru-PsrJ5l_pxxlGPxzAM4_XH8VfogXI8pWv2UDE1IguVeh371ESCbQbJ7SX2jgNzcvvZMMWs0syfF7P0BzGrh_ONsRcxmtjZgtcOA0TCu2-v8qx7GisgqOWOrzWs7ej5RUsu1sxtT53JG2Y3lrPrgajXTB56mSUaL9ivxEfUD17X_cUznGDNoVqcRdfa27rCtWqd8gL-C7M9bYYgcfpCRPllRvGmWP9oarrG4XoIO17QuhZ5tAoz8oFLM9o6pzR2CeDvmSqFbbTHXYdcpCuvYukIimZP6RruMU9O9YQjgCEGWx06WoTnDqWWjbrId8VqP0xJ_6w0j6av3EWGKLETBbaYRXys4OOy-JZRRHydg-es4tkir4xkMvIG8plxoz_mZbTyO9GA5tMHWzbQciUQFf95Gpiwsa5RDdGZx-guBAN56mtKnUzVG_PmUJ8-pzTATkjVpThBRLWaVPFi0eWLEc2NbF8",' - ' "e": "AQAB"}]}' + == '{"keys":[{"kty":"RSA","kid":"client_assertion_key","n":' + '"5nHd3aefARenRFQn-wVrjBLS-5A0uUiHWfOUt8EpjwE3wADAvVPKLbvRQJfugyrn_RpnLqqZFkwYrVD_u1Uzl9J17XJG75jGjCf-gVs1t9FPpgEsJGYK4RK2_f40AxAc6hKomB9q6_dqIxChDxVCrIrrWd9kRk0T86d8Ade3J4f_iMbremm3woSwI6QD056DkRtAD_v2PZQbUBgSru-PsrJ5l_pxxlGPxzAM4_XH8VfogXI8pWv2UDE1IguVeh371ESCbQbJ7SX2jgNzcvvZMMWs0syfF7P0BzGrh_ONsRcxmtjZgtcOA0TCu2-v8qx7GisgqOWOrzWs7ej5RUsu1sxtT53JG2Y3lrPrgajXTB56mSUaL9ivxEfUD17X_cUznGDNoVqcRdfa27rCtWqd8gL-C7M9bYYgcfpCRPllRvGmWP9oarrG4XoIO17QuhZ5tAoz8oFLM9o6pzR2CeDvmSqFbbTHXYdcpCuvYukIimZP6RruMU9O9YQjgCEGWx06WoTnDqWWjbrId8VqP0xJ_6w0j6av3EWGKLETBbaYRXys4OOy-JZRRHydg-es4tkir4xkMvIG8plxoz_mZbTyO9GA5tMHWzbQciUQFf95Gpiwsa5RDdGZx-guBAN56mtKnUzVG_PmUJ8-pzTATkjVpThBRLWaVPFi0eWLEc2NbF8",' + '"e":"AQAB"}]}' + ) + + assert ( + jwk.public_jwk().as_jwks().to_json(compact=False) + == ('{\n' + ' "keys": [\n' + ' {\n' + ' "kty": "RSA", \n' + ' "kid": "client_assertion_key", \n' + ' "n": ' + '"5nHd3aefARenRFQn-wVrjBLS-5A0uUiHWfOUt8EpjwE3wADAvVPKLbvRQJfugyrn_RpnLqqZFkwYrVD_u1Uzl9J17XJG75jGjCf-gVs1t9FPpgEsJGYK4RK2_f40AxAc6hKomB9q6_dqIxChDxVCrIrrWd9kRk0T86d8Ade3J4f_iMbremm3woSwI6QD056DkRtAD_v2PZQbUBgSru-PsrJ5l_pxxlGPxzAM4_XH8VfogXI8pWv2UDE1IguVeh371ESCbQbJ7SX2jgNzcvvZMMWs0syfF7P0BzGrh_ONsRcxmtjZgtcOA0TCu2-v8qx7GisgqOWOrzWs7ej5RUsu1sxtT53JG2Y3lrPrgajXTB56mSUaL9ivxEfUD17X_cUznGDNoVqcRdfa27rCtWqd8gL-C7M9bYYgcfpCRPllRvGmWP9oarrG4XoIO17QuhZ5tAoz8oFLM9o6pzR2CeDvmSqFbbTHXYdcpCuvYukIimZP6RruMU9O9YQjgCEGWx06WoTnDqWWjbrId8VqP0xJ_6w0j6av3EWGKLETBbaYRXys4OOy-JZRRHydg-es4tkir4xkMvIG8plxoz_mZbTyO9GA5tMHWzbQciUQFf95Gpiwsa5RDdGZx-guBAN56mtKnUzVG_PmUJ8-pzTATkjVpThBRLWaVPFi0eWLEc2NbF8", \n' + ' "e": "AQAB"\n' + ' }\n' + ' ]\n' + '}') ) @@ -144,11 +159,22 @@ def test_init_from_json() -> None: """ jwk = Jwk(j) assert ( - jwk.public_jwk().as_jwks().to_json() - == '{"keys": [{"kty": "RSA", "kid": "client_assertion_key", "n":' - ' "5nHd3aefARenRFQn-wVrjBLS-5A0uUiHWfOUt8EpjwE3wADAvVPKLbvRQJfugyrn_RpnLqqZFkwYrVD_u1Uzl9J17XJG75jGjCf-gVs1t9FPpgEsJGYK4RK2_f40AxAc6hKomB9q6_dqIxChDxVCrIrrWd9kRk0T86d8Ade3J4f_iMbremm3woSwI6QD056DkRtAD_v2PZQbUBgSru-PsrJ5l_pxxlGPxzAM4_XH8VfogXI8pWv2UDE1IguVeh371ESCbQbJ7SX2jgNzcvvZMMWs0syfF7P0BzGrh_ONsRcxmtjZgtcOA0TCu2-v8qx7GisgqOWOrzWs7ej5RUsu1sxtT53JG2Y3lrPrgajXTB56mSUaL9ivxEfUD17X_cUznGDNoVqcRdfa27rCtWqd8gL-C7M9bYYgcfpCRPllRvGmWP9oarrG4XoIO17QuhZ5tAoz8oFLM9o6pzR2CeDvmSqFbbTHXYdcpCuvYukIimZP6RruMU9O9YQjgCEGWx06WoTnDqWWjbrId8VqP0xJ_6w0j6av3EWGKLETBbaYRXys4OOy-JZRRHydg-es4tkir4xkMvIG8plxoz_mZbTyO9GA5tMHWzbQciUQFf95Gpiwsa5RDdGZx-guBAN56mtKnUzVG_PmUJ8-pzTATkjVpThBRLWaVPFi0eWLEc2NbF8",' - ' "e": "AQAB"}]}' + jwk.public_jwk().as_jwks().to_json(compact=True) + == '{"keys":[{"kty":"RSA","kid":"client_assertion_key","n":' + '"5nHd3aefARenRFQn-wVrjBLS-5A0uUiHWfOUt8EpjwE3wADAvVPKLbvRQJfugyrn_RpnLqqZFkwYrVD_u1Uzl9J17XJG75jGjCf-gVs1t9FPpgEsJGYK4RK2_f40AxAc6hKomB9q6_dqIxChDxVCrIrrWd9kRk0T86d8Ade3J4f_iMbremm3woSwI6QD056DkRtAD_v2PZQbUBgSru-PsrJ5l_pxxlGPxzAM4_XH8VfogXI8pWv2UDE1IguVeh371ESCbQbJ7SX2jgNzcvvZMMWs0syfF7P0BzGrh_ONsRcxmtjZgtcOA0TCu2-v8qx7GisgqOWOrzWs7ej5RUsu1sxtT53JG2Y3lrPrgajXTB56mSUaL9ivxEfUD17X_cUznGDNoVqcRdfa27rCtWqd8gL-C7M9bYYgcfpCRPllRvGmWP9oarrG4XoIO17QuhZ5tAoz8oFLM9o6pzR2CeDvmSqFbbTHXYdcpCuvYukIimZP6RruMU9O9YQjgCEGWx06WoTnDqWWjbrId8VqP0xJ_6w0j6av3EWGKLETBbaYRXys4OOy-JZRRHydg-es4tkir4xkMvIG8plxoz_mZbTyO9GA5tMHWzbQciUQFf95Gpiwsa5RDdGZx-guBAN56mtKnUzVG_PmUJ8-pzTATkjVpThBRLWaVPFi0eWLEc2NbF8",' + '"e":"AQAB"}]}' ) + assert jwk.public_jwk().as_jwks().to_json(compact=False) == ('{\n' + ' "keys": [\n' + ' {\n' + ' "kty": "RSA", \n' + ' "kid": "client_assertion_key", \n' + ' "n": ' + '"5nHd3aefARenRFQn-wVrjBLS-5A0uUiHWfOUt8EpjwE3wADAvVPKLbvRQJfugyrn_RpnLqqZFkwYrVD_u1Uzl9J17XJG75jGjCf-gVs1t9FPpgEsJGYK4RK2_f40AxAc6hKomB9q6_dqIxChDxVCrIrrWd9kRk0T86d8Ade3J4f_iMbremm3woSwI6QD056DkRtAD_v2PZQbUBgSru-PsrJ5l_pxxlGPxzAM4_XH8VfogXI8pWv2UDE1IguVeh371ESCbQbJ7SX2jgNzcvvZMMWs0syfF7P0BzGrh_ONsRcxmtjZgtcOA0TCu2-v8qx7GisgqOWOrzWs7ej5RUsu1sxtT53JG2Y3lrPrgajXTB56mSUaL9ivxEfUD17X_cUznGDNoVqcRdfa27rCtWqd8gL-C7M9bYYgcfpCRPllRvGmWP9oarrG4XoIO17QuhZ5tAoz8oFLM9o6pzR2CeDvmSqFbbTHXYdcpCuvYukIimZP6RruMU9O9YQjgCEGWx06WoTnDqWWjbrId8VqP0xJ_6w0j6av3EWGKLETBbaYRXys4OOy-JZRRHydg-es4tkir4xkMvIG8plxoz_mZbTyO9GA5tMHWzbQciUQFf95Gpiwsa5RDdGZx-guBAN56mtKnUzVG_PmUJ8-pzTATkjVpThBRLWaVPFi0eWLEc2NbF8", \n' + ' "e": "AQAB"\n' + ' }\n' + ' ]\n' + '}') def test_missing_kty() -> None: diff --git a/tests/test_jwk/test_jwks.py b/tests/test_jwk/test_jwks.py index 1ba89ee..427ead0 100644 --- a/tests/test_jwk/test_jwks.py +++ b/tests/test_jwk/test_jwks.py @@ -1,12 +1,14 @@ from __future__ import annotations +from typing import Mapping, Any + import pytest from jwskate import Jwk, JwkSet def test_jwkset() -> None: - keys = [ + keys: list[Mapping[str, Any]] = [ { "kty": "RSA", "n": "mUdmf5vJ3svsPSQ8BCOQVfwQdP8AmAEW21sYYUC5eSKR-pdwnRDBuFrIEjon2ry8cU-uaMjAoEZikPXcCTErye2Sj8fWQ8Wyo8DoGacJlFOJvs_18-CmNBc7oL8gBlYax3-feZZnaVIiJjvxQwUw5GQA6JTFnO8n2pnKMOOd8Gf6YrG-r0T6NXdviw0-2IW4f2UMJApqlu37yF8sgRNGZwDljNOkUtPK76Uz5T513Va4ckOqsVfnt4WoAkAkCl3eVBwGw3TJIbp_DaLUq53go0pXBCNxCHRD9mst69ZuknBLqn0SwKbQ9zJH9QvoqrEZ2q7GzkFzw70F6qH5MDEx2-dxQz_QccFV0XBpq4pkfuWzS8qKVO4QjyC7A0vIJUzrRHE2_moOtWvKTDsa7gfvK6kpnAW0iKnNchzBV0fzXWIIxRJ3_cc8Ue-KPRU9Wxm3heBOx_Qh-bKv9s9fVY9X6rimyX-pIwf-jkgWG8_FgTBuGkKTRcLi-XnwsCFIVNOtolmakbQHlin_lgDQm9s0nHoDJbZgAtzQfkIorclBJBzr2t__xgaZCfpSCLdwZFQvGEh1mK4WbSMMt5-L3zKsNLCBfdMbn2fS9n2hylfRwU_NZCY8f2RHAdP-z402Vq1c9-m2Ew3_695OmV5HoinJQPagY9hI-_EW8nhNWf8l4FE", @@ -165,3 +167,15 @@ def test_contains() -> None: assert key1.public_jwk() not in jwkset.jwks assert key2 not in jwkset.jwks + + +def test_update() -> None: + jwks = JwkSet() + assert not jwks.jwks + key1 = Jwk.generate(alg="ES256") + jwks.update(key1.as_jwks()) + assert key1 in jwks.jwks + key2 = Jwk.generate(alg="ES256") + jwks.update(key2.as_jwks()) + assert key2 in jwks.jwks + assert key1 in jwks.jwks # don't remove old keys