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 Multifernet.rotate method #3979

Merged
merged 25 commits into from Oct 18, 2017
Merged

Add Multifernet.rotate method #3979

merged 25 commits into from Oct 18, 2017

Conversation

derwolfe
Copy link
Contributor

This addresses #3882.

Adds a rotate(msgs, ttl) method to Multifernet to make it a easier to re-encrypt old tokens under a new primary key.

Thanks!

@derwolfe
Copy link
Contributor Author

I just realized that there should be some documentation for this as well.


rotated = mf2.rotate([mf1.encrypt(plaintext)])[0]

assert rotated != mf1_ciphertext
Copy link
Member

Choose a reason for hiding this comment

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

This assert would be true even if the rotation didn't occur because you're calling mf1.encrypt(plaintext) again.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

whoops - I intended to reuse mf1_ciphertext instead of encrypting it again.

@@ -134,6 +134,9 @@ def __init__(self, fernets):
def encrypt(self, msg):
return self._fernets[0].encrypt(msg)

def rotate(self, msgs, ttl=None):
return [self.encrypt(self.decrypt(m, ttl)) for m in msgs]
Copy link
Member

Choose a reason for hiding this comment

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

Why does this work on lists of messages? No other fernet APIs do.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The main use case I was targeting was rotating an entire set of secrets from one key to another. That said, this could just operate on a single token, and we could leave it up to the consumer to handle larger groups. @alex is that what you'd prefer?

@alex
Copy link
Member

alex commented Oct 13, 2017 via email

docs/fernet.rst Outdated
@@ -109,6 +115,34 @@ has support for implementing key rotation via :class:`MultiFernet`.
the front of the list to start encrypting new messages, and remove old keys
as they are no longer needed.

.. method:: rotate(msg, ttl=None)
Copy link
Member

Choose a reason for hiding this comment

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

Needs a .. versionadded:: 2.2

docs/fernet.rst Outdated
``ttl``, it is malformed, or
it does not have a valid
signature.
:raises TypeError: This exception is raised if the ```msg`` is not
Copy link
Member

Choose a reason for hiding this comment

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

Double back ticks here

docs/fernet.rst Outdated

:param bytes msg: The token to re-encrypt.
:param int ttl: Optionally, the number of seconds old a message may be
for it to be valid. If the message is older than
Copy link
Member

Choose a reason for hiding this comment

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

This is a petty style complaint but I prefer this with a single indent level for multiple lines and not aligned.

docs/fernet.rst Outdated
@@ -99,6 +100,11 @@ has support for implementing key rotation via :class:`MultiFernet`.
'...'
>>> f.decrypt(token)
'Secret message!'
>>> key3 = Fernet(Fernet.generate_key())
Copy link
Member

Choose a reason for hiding this comment

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

I think we should move the doctest example to under the rotate method itself

@alex
Copy link
Member

alex commented Oct 13, 2017

Can you pleaese add a changelog entry? (Haven't reviewed teh rest of this patch)

@derwolfe
Copy link
Contributor Author

derwolfe commented Oct 13, 2017 via email

docs/fernet.rst Outdated

Re-encrypts the provided fernet token. If the token has successfully
been re-encrypted, then the re-encrypted token will be returned. This
will raise an exception if re-encryption fails.
Copy link
Member

Choose a reason for hiding this comment

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

Please explain why someone would want this.

rotated = mf2.rotate(mf1_ciphertext)

assert rotated != mf1_ciphertext
assert mf2.decrypt(rotated) == plaintext
Copy link
Member

Choose a reason for hiding this comment

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

Please add an asseriotn that mf1 can't decrypt something rotated by mf2.

@alex
Copy link
Member

alex commented Oct 15, 2017

mmm, I just realized this resets the timestamp in the token. Is that intended? is that good?

@derwolfe
Copy link
Contributor Author

@alex good point. I think it is also made somewhat worse by the caller being able to pass in the TTL, thereby setting up the assumption that token might contain the same timestamp that it did when encrypted.

I don't really have a good solution for this. The following two options come to mind:

  1. I think it could be acceptable to have an enormous, flashing lights style warning in the docs stating that this will re-encrypt and thereby change the timestamp of the token. This does seem somewhat reasonable to me in that, were I to want to re-encrypt a bunch of fernet tokens due to a key compromise and were I relying on the timestamp stored on the token for something like a session, I'd likely want to to invalidate all sessions encrypted under the compromised key anyway.

    Further, with the current interface provided by Fernet/MultiFernet, there isn't a way to pass in a custom timestamp with a token. Given that, I'd expect (guess) that consumers of this are likely already handling TTL adjustments themselves. That said, I'm sure there are use cases that I'm not really considering (I've not used TTLs in practice at all).

  2. Another solution might be to make it possible to pass in a timestamp to use when rotating. I'm not sure if this would be advisable or in violation of the spec.

    Conceptually, fernet takes a user-provided message (an arbitrary sequence of bytes), a key (256 bits), and the current time, and produces a token, which contains the message in a form that can't be read or altered without the key.

    If we wanted to make a slightly different interface to Fernet that would allow the user to specify the current time, then tokens could be rotated using their original time. That said, we'd then have to make it relatively easy for users to extract that time from their tokens.

I do think that option #1 would be easier to understand and maintain. I also think that it most closely matches what a user would expect of a rotation function given Fernet's current interface.

What do you think?

Also, thank you for both for your reviews :)

@alex
Copy link
Member

alex commented Oct 15, 2017

I think the right thing to do is probably reuse the original timestamp; though I'd be interested in hearing @reaperhulk's thought.

The way to accomplish that would be to refactor the decrypt method to have an internal method that returned the timestamp, and then use that + the internal encrypt method.

@derwolfe
Copy link
Contributor Author

The way to accomplish that would be to refactor the decrypt method to have an internal method that returned the timestamp, and then use that + the internal encrypt method.

Didn't think of that! :) This does seem like a safer, more user friendly solution.

@reaperhulk
Copy link
Member

My preference would be for the default of rotate be ttl preservation. We could entirely remove the ttl arg, but it does seem reasonable for callers who use ttl to rotate and update ttl.

@derwolfe
Copy link
Contributor Author

Sounds good - when I get some time this week, I'll make the changes.

@alex
Copy link
Member

alex commented Oct 16, 2017

I actually think removing the ttl arg makes sense - ttl is about decryption, if rotate preserves the original timestamp, it's a "noop" in that sense.

@reaperhulk
Copy link
Member

@alex it doesn't seem out of the question for a caller to want to rotate the encryption key and update the ttl to a later time.

Of course, to allow ttl alteration and rotation the ttl arg actually needs to have 3 types of values. Preserve, no ttl, and a new ttl. In encrypt we have None as no ttl, but None can't be both no ttl and preserve ttl, so we'd need to do a sentinel or enum to handle that.

If we want to wait for a user to request ttl update we could minimize our API surface area by not supporting ttl update for now. A future PR could add the above with no backwards compatibility concerns.

@alex
Copy link
Member

alex commented Oct 16, 2017

There's two distinct things here:

a) Changin the stored timestamp
b) Enforcing the ttl argument against the timestamp.

I don't believe we should do either (and therefore we can drop the ttl argument).

@reaperhulk
Copy link
Member

I wrote a lot of words about various concerns I have but while doing so have talked myself into agreeing. Yes, since a Fernet token actually only encodes timestamp and the ttl is an arg to decrypt then I agree that rotation should not enforce a ttl.

I'm not convinced that we shouldn't support update_timestamp but in the absence of a user advocate for the feature I'm not going to fight for it.

return self._decrypt_data(data, timestamp, ttl)

@staticmethod
def _get_token_data(token):
Copy link
Member

Choose a reason for hiding this comment

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

Since this returns a timestamp that may be invalid (we haven't performed the HMAC verification), let's call this _get_unvalidated_token_data.

(For our purposes it's okay to not have validated it yet as the call to _decrypt_data does the HMAC check)

@reaperhulk reaperhulk dismissed their stale review October 16, 2017 12:31

obviated by updates

docs/fernet.rst Outdated
@@ -86,7 +86,8 @@ has support for implementing key rotation via :class:`MultiFernet`.
.. versionadded:: 0.7

This class implements key rotation for Fernet. It takes a ``list`` of
:class:`Fernet` instances, and implements the same API:
:class:`Fernet` instances, and implements the same API with the exception
Copy link
Member

Choose a reason for hiding this comment

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

nit: there shouldn't be a comma here (You didn't add it, but let's go ahead and fix it now)

docs/fernet.rst Outdated

.. versionadded:: 2.2

Rotating tokens is necessary when any of the fernet keys of a
Copy link
Member

Choose a reason for hiding this comment

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

I don't like this first sentence. Token rotation isn't just to limit the compromise window -- It's also for policy (rotation post-employee departure). Rotation in the event of actual compromise is a much more involved affair. Rotation as a best practice/cryptographic hygiene policy is designed to limit the damage in the event of an undetected event/increase the difficulty of attacks.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@reaperhulk does this work a bit better?

        Rotates a token by re-encrypting it under the :class:`MultiFernet`
        instance's primary key. This preserves the timestamp that was originally
        saved with the token. If a token has successfully been rotated then the
        rotated token will be returned. If rotation fails this will raise an
        exception.

        Token rotation is a best practice and manner of cryptographic hygiene
        designed to limit damage in the event of an undetected event and to
        increase the difficulty of attacks. For example, it would be necessary
        to rotate all fernet tokens in the event of an employee leaving who
        previously had access to the fernet key used to encrypt/decrypt fernet
        tokens.

I initially added the sentence "rotating tokens is necessary..." to try provide a reason for someone to want to use rotate. I'm wondering if it might make more sense to pull the second part of the docstring I've proposed out of rotate and into the docs for MultiFernet itself.

Copy link
Member

Choose a reason for hiding this comment

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

Yeah we have a section about key rotation in the multifernet class introduction so it probably does make sense to expand on it up there and link to the rotate method.

docs/fernet.rst Outdated
instance and then re-encrypts the token using the primary key. This
preserves the timestamp that was originally saved with the token.

If a token has successfully been rotated, then the rotated token will be
Copy link
Member

Choose a reason for hiding this comment

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

This comma and the one in the next sentence are both superfluous.

docs/fernet.rst Outdated
the key. This is URL-safe base64-encoded. This is referred to as a
"Fernet token".
:raises cryptography.fernet.InvalidToken: If a ``token`` is in any
way invalid, this exception is raised. A token may be invalid for a
Copy link
Member

Choose a reason for hiding this comment

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

"If a token is invalid this exception is raised" is probably all we need here.

@@ -21,6 +21,7 @@ cryptographic
cryptographically
Debian
decrypt
decrypts
Copy link
Member

Choose a reason for hiding this comment

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

I'd like to register my (continued) displeasure that enchant can't figure out plurals.

@@ -134,6 +141,20 @@ def __init__(self, fernets):
def encrypt(self, msg):
return self._fernets[0].encrypt(msg)

def rotate(self, msg):
timestamp, data = self._fernets[0]._get_unverified_token_data(msg)
Copy link
Member

Choose a reason for hiding this comment

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

Since this is a static method we can just call Fernet._get_unverified_token_data

else:
raise InvalidToken

iv = os.urandom(16)
Copy link
Member

Choose a reason for hiding this comment

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

Why does _encrypt_from_parts take an iv? The only other caller also does this os.urandom call. I don't think we want to have two places in our code where we generate the random IV. Maybe we should modify _encrypt_from_parts to just take data and timestamp and hoist iv generation into the method?

Copy link
Member

Choose a reason for hiding this comment

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

For the tests.

Copy link
Member

Choose a reason for hiding this comment

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

Ah. Well that's a good reason. I still don't like os.urandom being called in two places, but maybe I need to just be okay with that.

mf1_ciphertext = mf1.encrypt(plaintext)

original_time, _ = Fernet._get_unverified_token_data(mf1_ciphertext)
rotated_time, _ = Fernet._get_unverified_token_data(
Copy link
Member

Choose a reason for hiding this comment

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

This test would pass most of the time even if we weren't preserving timestamp since fernet timestamps have second granularity. I think we need to monkeypatch time to test this properly.

… due to calls occuring in less than one second
later = datetime.datetime.now() + datetime.timedelta(minutes=1)

t1 = time.mktime(earlier.timetuple())
t2 = time.mktime(later.timetuple())
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I only need to patch time once, after encryption. I'll change the PR to do that.

@@ -109,6 +110,50 @@ has support for implementing key rotation via :class:`MultiFernet`.
the front of the list to start encrypting new messages, and remove old keys
as they are no longer needed.

Token rotation as offered by :meth:`MultiFernet.rotate` is a best practice
Copy link
Member

Choose a reason for hiding this comment

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

This language still bugs me but I don't have constructive improvement to offer.

@reaperhulk reaperhulk merged commit af6f990 into pyca:master Oct 18, 2017
@github-actions github-actions bot locked as resolved and limited conversation to collaborators Aug 12, 2020
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants