Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add multi key and key id support. #33

Merged
merged 8 commits into from Apr 23, 2020

Conversation

nigoroll
Copy link

This PR consists of two patches:

  • The first adds support for accepting multiple keys
  • The second adds support for named keys (with key ids)

Please refer to the individual commit messages and the updated documentation.

A changelog will be added to the PR if/when otherwise accepted.

@nigoroll
Copy link
Author

The first commit is #32

@codecov-io
Copy link

codecov-io commented Feb 16, 2020

Codecov Report

Merging #33 into master will increase coverage by 0.22%.
The diff coverage is 100.00%.

Impacted file tree graph

@@            Coverage Diff             @@
##           master      #33      +/-   ##
==========================================
+ Coverage   97.28%   97.50%   +0.22%     
==========================================
  Files          19       19              
  Lines         442      481      +39     
  Branches       32       44      +12     
==========================================
+ Hits          430      469      +39     
  Misses          9        9              
  Partials        3        3              
Flag Coverage Δ
#codecov 97.50% <100.00%> (+0.22%) ⬆️
#dj111 97.02% <100.00%> (+0.26%) ⬆️
#dj20 97.02% <100.00%> (+0.26%) ⬆️
#dj21 97.02% <100.00%> (+0.26%) ⬆️
#dj22 97.02% <100.00%> (+0.26%) ⬆️
#dj30 97.50% <100.00%> (+0.22%) ⬆️
#drf310 97.02% <100.00%> (+0.26%) ⬆️
#drf311 97.50% <100.00%> (+0.22%) ⬆️
#drf37 97.02% <100.00%> (+0.26%) ⬆️
#drf38 97.02% <100.00%> (+0.26%) ⬆️
#drf39 97.02% <100.00%> (+0.26%) ⬆️
#py27 97.02% <100.00%> (+0.26%) ⬆️
#py35 97.02% <100.00%> (+0.26%) ⬆️
#py36 97.02% <100.00%> (+0.26%) ⬆️
#py37 97.02% <100.00%> (+0.26%) ⬆️
#py38 96.88% <100.00%> (+0.27%) ⬆️
Impacted Files Coverage Δ
src/rest_framework_jwt/settings.py 100.00% <ø> (ø)
src/rest_framework_jwt/utils.py 99.20% <100.00%> (+0.35%) ⬆️

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 612ba1e...4a4bf99. Read the comment docs.

@nigoroll
Copy link
Author

If I read the coverage report correctly, the delta stems from 2e124f6 which, as explained in the commit message, I this focuses on python 3.7 for a good reason.

@nigoroll nigoroll force-pushed the multi_algo branch 2 times, most recently from 9ba885d to fa59c52 Compare February 16, 2020 15:21
docs/index.md Outdated Show resolved Hide resolved
docs/index.md Outdated Show resolved Hide resolved
Copy link
Collaborator

@fitodic fitodic left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the PR!

There are a couple of points I would like to clarify before proceeding:

  1. The proposed settings schema: If I understood this correctly, each setting accepts a dict and a list. During the signing process, the first element is used, but there are issued with dicts not being ordered in until Python 3.7. Why not split these into multiple settings and drop the use of dicts where it isn't necessary or where order needs to be preserved?
  2. I suppose the dict settings are used to support named keys, but why is this necessary? Is it not enough to handle this via ordering or specifying only the specific keys? I believe adopting a more explicit settings schema would simplify the JWT decode process.

I appreciate your contribution and the time you've invested into this solution. You've mentioned that you are not primarily a Python developer, so I've added a couple of links for you to check out before proceeding.

My goal here is to assist you in making this solution as simple as possible, while preserving the functionality. I'm not a big fan of settings because each new setting increases the complexity exponentially when the entire code base is taken into account, but this library is structured in a way that they are unavoidable. That is why I have suggested splitting signing and verification keys and algorithms.

As to the variable names, there is no limit in the amount of characters you can use or a performance penalty, so feel free to use explanatory variables so other maintainers find it easy to read and maintain.

docs/index.md Outdated Show resolved Hide resolved
src/rest_framework_jwt/utils.py Outdated Show resolved Hide resolved
src/rest_framework_jwt/utils.py Outdated Show resolved Hide resolved
docs/index.md Show resolved Hide resolved
src/rest_framework_jwt/utils.py Show resolved Hide resolved
src/rest_framework_jwt/utils.py Outdated Show resolved Hide resolved
src/rest_framework_jwt/utils.py Show resolved Hide resolved
tests/views/test_authentication.py Outdated Show resolved Hide resolved
tests/views/test_authentication.py Outdated Show resolved Hide resolved
tests/views/test_authentication.py Outdated Show resolved Hide resolved
@ashokdelphia
Copy link

ashokdelphia commented Feb 17, 2020

Really happy to see support for multiple keys and key ids coming along.

It looks like someone could also use this to migrate between algorithms, which is also very welcome.

I'm a little unsure of the behaviour around a missing key id value. I would expect to only need to support that while migrating from an older scheme, to one with key ids.

I think there are three scenarios I would want to support:

  • someone is using this with an older configuration: no kid, a single key or secret and single algorithm
  • someone moving from an older configuration with no kid, to one with multiple, identified keys while still supporting the tokens issued with no kid; in this case I'd expect to identify in the configuration which key/secret and algorithm to use when the token has no kid
  • someone using multiple, identified keys; in this case I'd want to reject tokens that have no kid in the header

I don't think it is desirable to support a fourth case:

  • using multiple, identified keys, but allowing an attacker to make tokens that try all of the keys in sequence (this would be necessary to allow rotating unidentified keys, but I think that brings a lot of baggage with it)

Put another way: I think it should be possible to always pick a single algorithm and secret/key based on the header information supplied in the token, and the configuration.

@nigoroll
Copy link
Author

nigoroll commented Feb 18, 2020

Hi @fitodic,

thank you for your comprehensive review, I highly appreciate you taking the time to provide such helpful feedback.

I am going to respond to the overall comments first, then on the detailed comments on code

1. The proposed settings schema: If I understood this correctly, each setting accepts a `dict` and a `list`. During the signing process, the first element is used, but there are issued with `dict`s not being ordered in until Python 3.7. Why not split these into multiple settings and drop the use of dicts where it isn't necessary or where order needs to be preserved?

So this only affects JWT_SECRET_KEY, the symmetric signing secret setting. For asymmetric crypto, there already is the JWT_PRIVATE_KEY/JWT_PUBLIC_KEY separation.

As you suggest, one way to solve this would be to add something like a JWT_SECRET_SIGNING_KEY which would be required to contain either a scalar or a dict with exactly one member.

As you wrote later on, adding more settings seemed unattractive to me and as this only affected

  • users of python < 3.7
  • who want to use multiple (named) key ids
  • with symmetric signatures

I thought that keeping things simple was the best choice going forward, in particular as the old pythons will eventually become obsolete.

If you think that this is important, I guess our options are:

  • to use an OrderedDict
  • to use another setting like JWT_SECRET_SIGNING_KEY

Do you have a preference?

2. I suppose the `dict` settings are used to support named keys, but why is this necessary? Is it not enough to handle this via ordering or specifying only the specific keys? I believe adopting a more explicit settings schema would simplify the JWT decode process.

In general, key ids allow to specify which key is to be used, such that at most one verification attempt has to be made. To elaborate:

  • trying multiple keys is a waste of cycles, and in particular asymmetric crypto is relatively expensive. Maybe not that much in the realm of django, but, for example my machine does 15492.0 verifies/s for rsa 4096 (openssl speed rsa), which makes ~65us per RSA verify in the core openssl code alone. At least in my mind, this is something I would want to avoid

    • this aspect is particularly true for verifying JWTs on other systems: Consider a CDN node which has to verify JWTs for every request. For a system which will normally process ~10k req/s on one CPU code, one RSA might cut that performance roughly in half, an additional attempt takes it down to about a third etc.

    • But, true, for this scenario is is more important to produce JWTs with key ids than to verify with optimal efficiency in Django

  • naming keys removes guesswork, which I expect to be helpful for analyzing any potential issues. If a key id has been used for signing, that key id needs to verify ok.

As to the variable names (...)

Yeah, habits of a C developer. BTW, C symbol length does not matter performance wise either, this is more a style habit carried over.

@nigoroll
Copy link
Author

@fitodic I have used separate commits in response to your comments, IMHO these should be squashed before merge

@nigoroll
Copy link
Author

nigoroll commented Feb 18, 2020

Hi @ashokdelphia ,

It looks like someone could also use this to migrate between algorithms, which is also very welcome.

Yes, the motivation is to support all kinds of migrations as well as regular key rollover.

If I read your comment correctly, you agree that, if a kid header is present, we should only try a known key by that id.
But you are also saying that we should not always fall back to trying all the keys if there is no kid header, right? That would probably require changing the configuration or adding another flag like JWT_INSIST_ON_KID: Such a flag could be turned on after a transition period to refuse JWTs without a kid header.

What do you think?

@ashokdelphia
Copy link

But you are also saying that we should not always fall back to trying all the keys if there is no kid header, right? That would probably require changing the configuration or adding another flag like JWT_INSIST_ON_KID: Such a flag could be turned on after a transition period to refuse JWTs without a kid header.

What do you think?

I think you're right that this comes down to how to express it in the config.

I don't think a flag for 'insist on kid' is quite right; if I follow what you intend with that, it sounds like you'd still be looping over all of the keys when someone is mid-transition.

I would be tempted to allow fewer 'shapes' of configuration. Either you have a single key, which doesn't have a kid, or a set of keys each of which must have a kid. In the latter case, you could have an optional config variable for which kid should be used in the case of a token without a kid in the header.

The main case that would disallow would be rotating unidentified keys. I think that's naturally hazardous, and worth excluding so that by the time we're validating a token we always know which algorithm and key / secret should be used.

@nigoroll
Copy link
Author

nigoroll commented Mar 5, 2020

@ashokdelphia you got a valid point. Having less variants would be advantageous.
Yet IIUC your proposal would make it impossible to transition from multiple, unidentified keys to named keys (with a kid). I agree that this setup should be used, but for someone who already finds herself in that situation, there should be a migration option towards the better setup.
IOW, this migration option appears important to me, so being short of any better idea, I will go with JWT_INSIST_ON_KID now.

@ashokdelphia
Copy link

Yet IIUC your proposal would make it impossible to transition from multiple, unidentified keys to named keys (with a kid).

Perhaps I'm missing some essential point, but I don't think having multiple unidentified keys is possible at the moment.

I'm not sure it's good to support multiple unidentified keys at all, since I think it would be unsafe in general unless they were either all symmetric or all asymmetric keys. Even then, being able to try a list of keys sounds better for an attacker than for the defence.

If you implement it the way you're thinking will I be able to gracefully transition from a single unidentified key to multiple identified keys without having to allow trying multiple keys, potentially of different algorithms. (In my particular case, I want to move from HS256 to RS512, so I'm naturally concerned about the potential to confuse a public key and a symmetric secret.)

@nigoroll
Copy link
Author

nigoroll commented Mar 5, 2020

@ashokdelphia You are right, this code does not currently allow for multiple, unidentified keys.
Yet it could be used in an environment where JWTs are generated outside this module, or even outside Django.
In my world, rollover of unidentified keys is common practice, unfortunately, so while I would want to avoid it, I neither want to write code locking in people who find themselves in such environments.
In general, the associated risk is the added validation overhead, as explained in the second half of this comment. This risk will be avoided specifically with JWT_INSIST_ON_KID and I do not see any other (security) related risks. In other words, if your policy is to not support multiple, unidentified keys, you will be able to enforce just that.

Let's go through your scenario (please forgive any syntax errors in this mockup code):

  • you start off with
"JWT_SECRET":"HMAC_KEY"
  • you add your RSA keypair and specify that you want to use RSA for signing, but still accept HMACs:
"JWT_SECRET":"HMAC_KEY",
"JWT_PRIVATE_KEY": {"kid": load_pem_private_key(...)},
"JWT_PUBLIC_KEY": {"kid": load_pem_public_key(...)},
"JWT_INSIST_ON_KID": True,
"JWT_ALGORITHM": ["RS256", "HS256"],
  • Wait until your HS256 JWTs expire, then remove HS* support:
"JWT_SECRET": None,
"JWT_PRIVATE_KEY": {"kid": load_pem_private_key(...)},
"JWT_PUBLIC_KEY": {"kid": load_pem_public_key(...)},
"JWT_INSIST_ON_KID": True,
"JWT_ALGORITHM": "RS256",
  • Roll over by signing with a new private key, still accepting the old one
"JWT_SECRET": None,
"JWT_PRIVATE_KEY": {"nukid": load_pem_private_key(...)},
"JWT_PUBLIC_KEY": {"nukid": load_pem_public_key(...), "kid": load_pem_public_key(...)},
"JWT_INSIST_ON_KID": True,
"JWT_ALGORITHM": "RS256",

@ashokdelphia
Copy link

@nigoroll: Thanks for the detailed example. I think I was misunderstanding how INSIST_ON_KID would work. In step 2 there, it sounds like INSIST_ON_KID would be set, but we'd still be accepting symmetric tokens with no kid, which would indeed allow what I was asking about.

I'll take a closer look at the implementation later; thanks.

@nigoroll
Copy link
Author

nigoroll commented Mar 5, 2020

@ashokdelphia I still have not written the INSIST_ON_KID code so there is nothing for you to look at more closely yet.
I should be ready in an hour or so, please check back then.

@nigoroll
Copy link
Author

nigoroll commented Mar 5, 2020

I have force-pushed an update which addresses feedback:

  • To solve the dict ordering issue, we really do not need to do much except to advise users of python versions < 3.7 to use an OrderedDict instead of an ordinary dict
  • INSIST_ON_KID has been added, see previous notes for detailed discussion.

@ashokdelphia
Copy link

I have force-pushed an update which addresses feedback:

Thanks for being defensive about the potential for crossing symmetric and asymmetric algorithms.

I also appreciate how you've kept that separate, and also allowed INSIST_ON_KID to permit kid-less tokens as long as key ids are not defined in the relevant config. That will make this a good fit for a migration I need to do shortly.

@nigoroll
Copy link
Author

nigoroll commented Mar 7, 2020

force-pushed adressing @ashokdelphia 's feedback

Copy link
Collaborator

@fitodic fitodic left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@nigoroll Thank you for the time and effort you've put into this. I see you and @ashokdelphia really hashed this out so thank you both 🙂

I have nothing more to add apart from the comments requesting minor changes that would ease the future maintenance of this library.

I've also seen that some edge cases haven't been covered, primarily:

  1. The algorithm in the header is not found in the updated list of algorithms (InvalidAlgorithmError)
  2. The InvalidKeyError is raised when the kid is not found among the specified keys
  3. The JWT couldn't be decoded

Given the sheer amount of configuration possibilities that are now available, I cannot be sure if any more pressing edge cases have been omitted from the test suite. If you or @ashokdelphia think of something, please feel free to mention them or add them.

docs/index.md Outdated Show resolved Hide resolved
docs/index.md Outdated Show resolved Hide resolved
docs/index.md Outdated Show resolved Hide resolved
docs/index.md Outdated Show resolved Hide resolved
docs/index.md Outdated Show resolved Hide resolved
tests/views/test_authentication.py Show resolved Hide resolved
tests/views/test_authentication.py Outdated Show resolved Hide resolved
tests/views/test_authentication.py Outdated Show resolved Hide resolved
tests/views/test_authentication.py Outdated Show resolved Hide resolved
src/rest_framework_jwt/utils.py Outdated Show resolved Hide resolved
nigoroll added a commit to nigoroll/django-rest-framework-jwt that referenced this pull request Apr 21, 2020
nigoroll added a commit to nigoroll/django-rest-framework-jwt that referenced this pull request Apr 23, 2020
Existing code made key rollovers or algorithm changes hard and basically
required a breaking change: Once any of JWT_ALGORITHM, JWT_SECRET_KEY,
or JWT_PRIVATE_KEY/JWT_PUBLIC_KEY were changed, existing tokens were
rendered invalid.

We now support JWT_ALGORITHM, JWT_SECRET_KEY, and JWT_PUBLIC_KEY
optionally being a list, where all members are accepted as valid.

When JWT_SECRET_KEY is a list, the first member is used for signing and
all others are accepted for verification.
nigoroll added a commit to nigoroll/django-rest-framework-jwt that referenced this pull request Apr 23, 2020
@nigoroll
Copy link
Author

After another minor change it seems we are getting a green light from travis now. I will squash commits.

The previous commit added support for multiple keys, which (despite
being useful as a fallback, if anything) implies multiple verification
attempts and thus is not computationally efficient.

We now support identifing keys by key id ("kid" header): When a JWT
carries a key id, we can identify immediately if it is known and only
need to make at most one verification attempt.

To configure keys with ids, JWT_SECRET_KEY, JWT_PRIVATE_KEY and
JWT_PUBLIC_KEY can now also be a dict in the form

	{ "kid1": key1, "kid2": key2, ... }

When a JWT does not carry a key id ("kid" header), the default is to
fall back to trying all keys if keys are named (defined as a dict).
Setting JWT_INSIST_ON_KID: True avoids this fallback and requires any
JWT to be validated to carry a key id _if_ key IDs are used

Closes Styria-Digital#31
@nigoroll
Copy link
Author

squash done

@nigoroll nigoroll requested a review from fitodic April 23, 2020 08:27
Copy link
Collaborator

@fitodic fitodic left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've left two comments pertaining to the documentation.

Scrolling down I've noticed there some unresolved discussion. I suppose you'll address them later when you get to them. Otherwise, things are looking great. 👍

changelog.d/33.feature.md Outdated Show resolved Hide resolved
docs/index.md Outdated Show resolved Hide resolved
@nigoroll
Copy link
Author

@fitodic Sorry for having missed some conversations, gh hid them from me by default and I assumed that it would only do that for resolved conversations. Looking at them now

@fitodic
Copy link
Collaborator

fitodic commented Apr 23, 2020

@fitodic Sorry for having missed some conversations, gh hid them from me by default and I assumed that it would only do that for resolved conversations. Looking at them now

Don't worry about it, happens to all of us. That's why I started reviewing everything anew from the /files endpoint some time ago. At least GitHub has the Mark as viewed checkbox next to each file 😆

@nigoroll
Copy link
Author

@fitodic I feel bad about wasting your time with these little details, thank you for your thorough work.
I hope all comments are addressed now.

@fitodic
Copy link
Collaborator

fitodic commented Apr 23, 2020

@fitodic I feel bad about wasting your time with these little details, thank you for your thorough work.
I hope all comments are addressed now.

After all your hard work, it's the least I can do. 🙂

@fitodic fitodic merged commit b90368d into Styria-Digital:master Apr 23, 2020
@fitodic
Copy link
Collaborator

fitodic commented Apr 23, 2020

@nigoroll I've squashed the commits as you've requested. This feature definitely improves the overall quality of this library so once again, thank you for your contribution and patience. 🙂

@nigoroll
Copy link
Author

@fitodic it has been a pleasure working with you

@nigoroll nigoroll deleted the multi_algo branch April 23, 2020 11:01
@fitodic
Copy link
Collaborator

fitodic commented Apr 23, 2020

@fitodic it has been a pleasure working with you

It has been a pleasure working with you too 🙂 The new release is available on PyPI, and the documentation has been updated.

@nigoroll
Copy link
Author

thx. Seeing there html-render of the docs I noticed that we could look after some indentation fixes still and maybe give the settings own paragraphs. I might get back to that

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

4 participants