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

Hash token values for storage #41792

Merged
merged 12 commits into from
May 20, 2019
Merged

Conversation

jkakavas
Copy link
Member

@jkakavas jkakavas commented May 3, 2019

This commit changes how access tokens and refresh tokens are stored
in the tokens index and is a followup to #39631

Access token values are now hashed before being stored in the id
field of the user_token and before becoming part of the token
document id. Refresh token values are hashed before being stored
in the token field of the refresh_token. The tokens are hashed
without a salt value since these are v4 UUID values that have
enough entropy themselves. Both rainbow table attacks and offline
brute-force attacks are impractical.

As a side effect of this change and in order to support multiple
concurrent refreshes as introduced in #39631, upon refreshing an
<access token, refresh token> pair, the superseding access token
and refresh tokens values are stored in the superseded token doc,
encrypted with a key that is derived from the superseded refresh
token. As such, subsequent requests to refresh the same token in
the predefined time window will return the same superseding access
token and refresh token values, without hitting the tokens index
(as this only stores hashes of the token values). AES in GCM
mode is used for encrypting the token values and the key
derivation from the superseded refresh token uses a small number
of iterations as it needs to be quick.

For backwards compatibility reasons, the new behavior is only
enabled when all nodes in a cluster are in the required version
so that old nodes can cope with the token values in a mixed
cluster during a rolling upgrade.

Resolves #40765

This commit changes how access tokens and refresh tokens are stored
in the tokens index.

Access token values are now hashed before being stored in the id
field of the `user_token` and before becoming part of the token
document id. Refresh token values are hashed before being stored
in the token field of the `refresh_token`. The tokens are hashed
without a salt value since these are v4 UUID values that have
enough entropy themselves. Both rainbow table attacks and offline
bruteforce attacks are impractical.

As a side effect of this change and in order to support multiple
concurrent refreshes as introduced in elastic#39631, upon refreshing an
<access token, refresh token> pair, the superseding access token
and refresh tokens values are stored in the superseded token doc,
encrypted with a key that is derived from the superseded refresh
token. As such, subsequent requests to refresh the same token in
the predefined time window will return the same superseding access
token and refresh token values, without hitting the tokens index
(as this only stores hashes of the token values). AES in GCM
mode is used for encrypting the token values and the key
derivation from the superseded refresh token uses a small number
of iterations as it needs to be quick.

For backwards compatibility reasons, the new behavior is only
enabled when all nodes in a cluster are in the required version
so that old nodes can cope with the token values in a mixed
cluster during a rolling upgrade.
@jkakavas jkakavas added >enhancement :Security/Authentication Logging in, Usernames/passwords, Realms (Native/LDAP/AD/SAML/PKI/etc) v8.0.0 v7.2.0 labels May 3, 2019
@elasticmachine
Copy link
Collaborator

Pinging @elastic/es-security

@@ -157,11 +158,12 @@
* Cheat Sheet</a> and the <a href="https://pages.nist.gov/800-63-3/sp800-63b.html#sec5">
* NIST Digital Identity Guidelines</a>
*/
private static final int ITERATIONS = 100000;
static final int TOKEN_SERVICE_KEY_ITERATIONS = 100000;
static final int TOKENS_ENCRYPTION_KEY_ITERATIONS = 1024;
Copy link
Member Author

Choose a reason for hiding this comment

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

The key is derived from a random sting, we don't need too many iterations, we want this to be quick and not computationally expensive.

} else {
// The token was created in a < VERSION_ACCESS_TOKENS_UUIDS cluster so we need to decrypt it to get the tokenId
if (in.available() < MINIMUM_BASE64_BYTES) {
logger.debug("invalid token, smaller than [{}] bytes", MINIMUM_BASE64_BYTES);
if (in.available() < LEGACY_MINIMUM_BYTES) {
Copy link
Member Author

Choose a reason for hiding this comment

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

This is base64 decoded already so we don't need to check MINIMUM_BASE64_BYTES

// In AES GCM we cannot reuse the same IV. We predictably generate the IV for the second decryption instead of
// storing an extra field, since it doesn't have to be unpredictable, just not reused with the same key.
byte[] iv2 = new byte[iv.length];
System.arraycopy(iv, iv.length / 2, iv2, 0, iv.length / 2);
Copy link
Member Author

@jkakavas jkakavas May 3, 2019

Choose a reason for hiding this comment

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

This has no particular cryptographic use, we just need a predictable way to get a new IV for the second encryption,

Copy link
Contributor

@albertzaharovits albertzaharovits left a comment

Choose a reason for hiding this comment

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

Overall it looks like a simplification, axing the superseded_by reference, which is very welcome!

I see that in addition to the hashed token we have to store it encrypted format so that we can return the plaintext on concurrent refreshes. I think that's alright as well.

I would like to propose another simplification, that I believe will lessen the implementation complexity, even though it will require another iteration. I am thinking of completely removing the refresh_token and use the access_token as a refresh token. That is, removing the refresh_token in the token document, the response will include a refresh_token field, but getting the token doc from the refresh_token would use the same hash and get_by_id . I think this way we avoid storing two pairs of hashes and encrypted formats, and use only one.

What do you think? Do you think it would help make this PR leaner?

"type": "binary"
},
"superseding_encryption_salt": {
"type": "binary"
Copy link
Contributor

Choose a reason for hiding this comment

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

I would move superseding_encryption_iv and superseding_encryption_salt as data in the refresh token, the same way the standalone access_tokens were encrypted before.
The idea is to minimize the number of mapped fields, and also "hide" the implementation details.

if (false == refreshTokenVersion.onOrAfter(VERSION_TOKENS_INDEX_INTRODUCED)
|| unencodedRefreshToken.length() != TOKEN_ID_LENGTH) {
logger.debug("Decoded refresh token [{}] with version [{}] is invalid.", unencodedRefreshToken, refreshTokenVersion);
if (refreshToken.length() == HASHED_TOKEN_LENGTH) {
Copy link
Contributor

Choose a reason for hiding this comment

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

When is this if branch considered?

Copy link
Member Author

Choose a reason for hiding this comment

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

This is considered during IDP initiated SAML logout when we need to find relevant tokens based on the token metadata ( that contain the SAML NameID ) and then invalidate those explicitly. See https://github.com/elastic/elasticsearch/blob/9beb31fd3c5a8323cb08cc524f1a2268e9c72c24/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/saml/TransportSamlInvalidateSessionAction.java

@jkakavas
Copy link
Member Author

jkakavas commented May 6, 2019

Thanks for the 👀 @albertzaharovits

I would like to propose another simplification, that I believe will lessen the implementation complexity, even though it will require another iteration. I am thinking of completely removing the refresh_token and use the access_token as a refresh token. That is, removing the refresh_token in the token document, the response will include a refresh_token field, but getting the token doc from the refresh_token would use the same hash and get_by_id . I think this way we avoid storing two pairs of hashes and encrypted formats, and use only one.

Could you give it another try to explain the above for my benefit ? I'm not sure I can follow this

@jkakavas
Copy link
Member Author

jkakavas commented May 7, 2019

Thanks for the eyes @albertzaharovits

I would like to propose another simplification, that I believe will lessen the implementation complexity, even though it will require another iteration. I am thinking of completely removing the refresh_token and use the access_token as a refresh token. That is, removing the refresh_token in the token document, the response will include a refresh_token field, but getting the token doc from the refresh_token would use the same hash and get_by_id . I think this way we avoid storing two pairs of hashes and encrypted formats, and use only one.

Could you give it another try to explain the above for my benefit ? I'm not sure I can follow this

We discussed this in person with @albertzaharovits and concluded that

  • There is no benefit in removing the refresh token and it will potentially differentiate us too much from oAuth2 spec
  • A superseding object (not indexed) will be introduced in the mapping to hold all extra fields
  • Will attempt to minimize the number of newly introduced fields ( i.e. concatenate superseding_access_token and superseding_refresh_token to one field before encryption and split values after decryption )

Copy link
Contributor

@tvernum tvernum left a comment

Choose a reason for hiding this comment

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

I haven't reviewed the tests yet, but since you're planning a couple of changes I'll submit my main comments now.

I'm starting to get concerned about the number of places we're passing around Tuple<String,String> and it doesn't always mean the same thing.
Sometimes the 2nd string is a plain refresh token, sometimes it's a hashed refresh token. Maybe there's other combinations, it's hard to tell.
I don't have a preferred solution, but I think types are important, and the code is getting harder to follow because we're not clearly defining those types.

} else {
// prior versions are not version-prepended, as nodes on those versions don't expect it.
// Such nodes might exist in a mixed cluster during a rolling upgrade.
listener.onResponse(new Tuple<>(userToken, plainRefreshToken));
listener.onResponse(new Tuple<>(versionedAccessToken, plainRefreshToken));
Copy link
Contributor

Choose a reason for hiding this comment

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

Is this change correct? The comment implies it should not be versioned.

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, this is correct. The comment refers to the refresh token (.. too many versions and possibilities to keep track of ). I've updated the comment

- Move superseding encrypted token data in a separate object
- Concatenate tokens before encryption and split after decryption
  so that we only have 1 crypto operation and one less field in
  the mapping
- Add javadoc
@jkakavas jkakavas requested a review from tvernum May 10, 2019 10:52
@jkakavas
Copy link
Member Author

I'm starting to get concerned about the number of places we're passing around Tuple<String,String> and it doesn't always mean the same thing.

All instances used are Tuples containing the serialized access token and the serialized refresh token, as these will be returned to the caller of our APIs. I've updated the javadoc where applicable to denote this.

@jkakavas
Copy link
Member Author

@tvernum this is ready for a review round

@tvernum
Copy link
Contributor

tvernum commented May 20, 2019

All instances used are Tuples containing the serialized access token and the serialized refresh token,

In line 1420 we return a hashedRefreshToken, but I think the other cases are all unhashed (but versioned, unless in BWC mode).

Copy link
Contributor

@tvernum tvernum left a comment

Choose a reason for hiding this comment

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

LGTM, with a few minor nits.

@jkakavas jkakavas merged commit 307bc17 into elastic:master May 20, 2019
jkakavas added a commit to jkakavas/elasticsearch that referenced this pull request May 20, 2019
This commit changes how access tokens and refresh tokens are stored
in the tokens index.

Access token values are now hashed before being stored in the id
field of the `user_token` and before becoming part of the token
document id. Refresh token values are hashed before being stored
in the token field of the `refresh_token`. The tokens are hashed
without a salt value since these are v4 UUID values that have
enough entropy themselves. Both rainbow table attacks and offline
brute force attacks are impractical.

As a side effect of this change and in order to support multiple
concurrent refreshes as introduced in elastic#39631, upon refreshing an
<access token, refresh token> pair, the superseding access token
and refresh tokens values are stored in the superseded token doc,
encrypted with a key that is derived from the superseded refresh
token. As such, subsequent requests to refresh the same token in
the predefined time window will return the same superseding access
token and refresh token values, without hitting the tokens index
(as this only stores hashes of the token values). AES in GCM
mode is used for encrypting the token values and the key
derivation from the superseded refresh token uses a small number
of iterations as it needs to be quick.

For backwards compatibility reasons, the new behavior is only
enabled when all nodes in a cluster are in the required version
so that old nodes can cope with the token values in a mixed
cluster during a rolling upgrade.
jkakavas added a commit that referenced this pull request May 20, 2019
This commit changes how access tokens and refresh tokens are stored
in the tokens index.

Access token values are now hashed before being stored in the id
field of the `user_token` and before becoming part of the token
document id. Refresh token values are hashed before being stored
in the token field of the `refresh_token`. The tokens are hashed
without a salt value since these are v4 UUID values that have
enough entropy themselves. Both rainbow table attacks and offline
brute force attacks are impractical.

As a side effect of this change and in order to support multiple
concurrent refreshes as introduced in #39631, upon refreshing an
<access token, refresh token> pair, the superseding access token
and refresh tokens values are stored in the superseded token doc,
encrypted with a key that is derived from the superseded refresh
token. As such, subsequent requests to refresh the same token in
the predefined time window will return the same superseding access
token and refresh token values, without hitting the tokens index
(as this only stores hashes of the token values). AES in GCM
mode is used for encrypting the token values and the key
derivation from the superseded refresh token uses a small number
of iterations as it needs to be quick.

For backwards compatibility reasons, the new behavior is only
enabled when all nodes in a cluster are in the required version
so that old nodes can cope with the token values in a mixed
cluster during a rolling upgrade.
talevy added a commit to talevy/elasticsearch that referenced this pull request May 21, 2019
some tests are failing after the introduction of elastic#41792.

relates elastic#42267 and elastic#42289.
talevy added a commit that referenced this pull request May 21, 2019
some tests are failing after the introduction of #41792.

relates #42267 and #42289.
talevy added a commit that referenced this pull request May 21, 2019
some tests are failing after the introduction of #41792.

relates #42267 and #42289.
gurkankaymak pushed a commit to gurkankaymak/elasticsearch that referenced this pull request May 27, 2019
This commit changes how access tokens and refresh tokens are stored
in the tokens index.

Access token values are now hashed before being stored in the id
field of the `user_token` and before becoming part of the token
document id. Refresh token values are hashed before being stored
in the token field of the `refresh_token`. The tokens are hashed
without a salt value since these are v4 UUID values that have
enough entropy themselves. Both rainbow table attacks and offline
brute force attacks are impractical.

As a side effect of this change and in order to support multiple
concurrent refreshes as introduced in elastic#39631, upon refreshing an
<access token, refresh token> pair, the superseding access token
and refresh tokens values are stored in the superseded token doc,
encrypted with a key that is derived from the superseded refresh
token. As such, subsequent requests to refresh the same token in
the predefined time window will return the same superseding access
token and refresh token values, without hitting the tokens index
(as this only stores hashes of the token values). AES in GCM
mode is used for encrypting the token values and the key
derivation from the superseded refresh token uses a small number
of iterations as it needs to be quick.

For backwards compatibility reasons, the new behavior is only
enabled when all nodes in a cluster are in the required version
so that old nodes can cope with the token values in a mixed
cluster during a rolling upgrade.
gurkankaymak pushed a commit to gurkankaymak/elasticsearch that referenced this pull request May 27, 2019
some tests are failing after the introduction of elastic#41792.

relates elastic#42267 and elastic#42289.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
>enhancement :Security/Authentication Logging in, Usernames/passwords, Realms (Native/LDAP/AD/SAML/PKI/etc) v7.2.0 v8.0.0-alpha1
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Hashing of access tokens values for storage
5 participants