Skip to content

move fernet creation to helper method #198

New issue

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

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

Already on GitHub? # to your account

Merged
merged 6 commits into from
Oct 22, 2023

Conversation

terencehonles
Copy link
Contributor

This change moves the fernet creation to a helper method in order to make it easier to either encrypt or decrypt values in the config/cache file. The motivation for this change is to make it easier to debug issues or to re-use OAuth tokens obtained outside the proxy.

emailproxy.py Outdated
Comment on lines 590 to 591
return ('%s: Invalid `token_salt` value found in config file entry for account %s - this value is not '
'intended to be manually created; please remove and retry' % (APP_NAME, username))
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This could also be rewritten to stomp on previous values and just show a warning instead.

Copy link
Owner

Choose a reason for hiding this comment

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

This is the post in question that for whatever reason gave that incorrect advice and led to a few invalid issues being opened (and this code being added). Traffic seems to have subsided from that source recently, so yes, replacing the value with a new salt and just showing a warning would probably be fine. So, something like:

        # we hash locally-stored tokens with the given password
        token_salt = config.get(username, 'token_salt', fallback=None)

        # generate encrypter/decrypter based on password and random salt
        try:
            decoded_salt = base64.b64decode(token_salt.encode('utf-8'))  # catch incorrect third-party proxy guide
        except binascii.Error:
            Log.info('%s: Invalid `token_salt` value found in config file entry for account %s - this value is not '
                    'intended to be manually created; please remove and retry' % (APP_NAME, username))
            token_salt = None

        if not token_salt:
            token_salt = base64.b64encode(os.urandom(16)).decode('utf-8')

        config.set(username, 'token_salt', token_salt)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ok, updating the PR to generate the salt on error

Copy link
Owner

@simonrob simonrob 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. I don't have strong feelings either way about this, so it'd be useful if you could give a bit more detail about how it makes things easier. Is that because you'd rewrite or monkey-patch this function to change its behaviour? And how would it help with debugging issues or reusing externally-obtained OAuth tokens?

emailproxy.py Outdated
Comment on lines 665 to 667
# check if there was an error getting the encryption info
if not isinstance(fernet, Fernet):
return (False, fernet)
Copy link
Owner

Choose a reason for hiding this comment

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

Can this ever actually happen?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This was just to catch the old invalid salt and continue exiting like the previous behavior

emailproxy.py Outdated
Comment on lines 590 to 591
return ('%s: Invalid `token_salt` value found in config file entry for account %s - this value is not '
'intended to be manually created; please remove and retry' % (APP_NAME, username))
Copy link
Owner

Choose a reason for hiding this comment

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

This is the post in question that for whatever reason gave that incorrect advice and led to a few invalid issues being opened (and this code being added). Traffic seems to have subsided from that source recently, so yes, replacing the value with a new salt and just showing a warning would probably be fine. So, something like:

        # we hash locally-stored tokens with the given password
        token_salt = config.get(username, 'token_salt', fallback=None)

        # generate encrypter/decrypter based on password and random salt
        try:
            decoded_salt = base64.b64decode(token_salt.encode('utf-8'))  # catch incorrect third-party proxy guide
        except binascii.Error:
            Log.info('%s: Invalid `token_salt` value found in config file entry for account %s - this value is not '
                    'intended to be manually created; please remove and retry' % (APP_NAME, username))
            token_salt = None

        if not token_salt:
            token_salt = base64.b64encode(os.urandom(16)).decode('utf-8')

        config.set(username, 'token_salt', token_salt)

This change moves the fernet creation to a helper method in order to
make it easier to either encrypt or decrypt values in the config/cache
file. The motivation for this change is to make it easier to debug
issues or to re-use OAuth tokens obtained outside the proxy.
@terencehonles terencehonles force-pushed the add-cryptographer-helper-method branch from 2fdde19 to 4ed058e Compare October 15, 2023 09:24
Copy link
Contributor Author

@terencehonles terencehonles 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. I don't have strong feelings either way about this, so it'd be useful if you could give a bit more detail about how it makes things easier. Is that because you'd rewrite or monkey-patch this function to change its behaviour? And how would it help with debugging issues or reusing externally-obtained OAuth tokens?

It just makes it easier since the logic is encapsulated in one place and it makes it easier (possible) to run something like:

>>> import emailproxy, getpass
>>> config = emailproxy.AppConfig.get()
>>> username = input('Username: ')
Username: example@example.com
>>> cryptographer = emailproxy.OAuth2Helper.get_encryption_info(config, username, getpass.getpass())[0]
Password: 
>>> print("Refresh token: ", emailproxy.OAuth2Helper.decrypt(cryptographer, config.get(username, 'refresh_token')))
...

It's also possible to write new tokens following the same pattern as ^^^.

I wouldn't expect someone to monkey patch the function, but it's true that it does allow a way to potentially change the encryption method, though that would mean that OAuth2Helper.encrypt and OAuth2Helper.decrypt would also need to be updated and if making the encryption method be configurable is a goal then it probably makes sense to move everything to a class and create an instance that has a single interface.

emailproxy.py Outdated
Comment on lines 590 to 591
return ('%s: Invalid `token_salt` value found in config file entry for account %s - this value is not '
'intended to be manually created; please remove and retry' % (APP_NAME, username))
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ok, updating the PR to generate the salt on error

emailproxy.py Outdated
Comment on lines 665 to 667
# check if there was an error getting the encryption info
if not isinstance(fernet, Fernet):
return (False, fernet)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This was just to catch the old invalid salt and continue exiting like the previous behavior

emailproxy.py Outdated
token_salt = base64.b64encode(decoded_salt).decode('ascii')

# generate encrypter/decrypter based on password and random salt
key_derivation_function = PBKDF2HMAC(algorithm=hashes.SHA256(), length=32, salt=decoded_salt, iterations=100000,
Copy link
Contributor Author

Choose a reason for hiding this comment

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

It looks like this is a bit low based on current recommendations. I believe this is not something that can be just updated and two different fernets probably need to be created and the old one can be used as a fallback when decrypting. A change like this is out of scope of this PR, and would require confirming a migration would be necessary.

Copy link
Owner

Choose a reason for hiding this comment

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

Yes – this is something I looked at improving a while ago, but it was hard to find a good way to migrate. Since it's a locally-stored encrypted value I felt the risk was quite low, but if you can suggest a way to do this that isn't onerous from the user side I'm happy to look at it.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It looks like it might be relatively straightforward to do with a https://cryptography.io/en/latest/fernet/#cryptography.fernet.MultiFernet but you'd want to probably store the number of iterations used so that way you could run the migration and re-save the config when needed.

@terencehonles
Copy link
Contributor Author

The added commit should address #198 (comment), but I left it as a separate commit in case the change should be backed out from this PR.

@terencehonles terencehonles force-pushed the add-cryptographer-helper-method branch 2 times, most recently from f39767e to f582366 Compare October 16, 2023 11:20
@terencehonles terencehonles force-pushed the add-cryptographer-helper-method branch from f582366 to 2dc9715 Compare October 16, 2023 11:21
Copy link
Owner

@simonrob simonrob left a comment

Choose a reason for hiding this comment

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

I was just about to take a look at iteration upgrading via MultiFernet, but saw this. Thanks for the great work!

emailproxy.py Outdated
@@ -570,6 +570,79 @@ def _save_cache(cache_store_identifier, output_config_parser):
Log.error('Error saving state to cache store file at', cache_store_identifier, '- is the file writable?')


class Cryptographer:
ITERATIONS = 870000 # taken from cryptography's suggestion of using Django's defaults
LEGACY_ITERATIONS = 100000
Copy link
Owner

Choose a reason for hiding this comment

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

What do you think about keeping a list of previous iteration counts given that the recommendation changes fairly often? (e.g., from 480,000 in December 2022 to 870,000 under a year later)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Well this change stores the number of iterations in the config, so right now the options it tries are: the initial default 100_000, if there's a value in the config, and the new default 870_000, going forward if the config is updated it will store the value in the config, so it should really never go past that and the legacy value is just for historical reasons.

Ideally it's just trying [ITERATIONS, configured_iterations] and if the config is up to date that's actually just [ITERATIONS] when uniqued.

Copy link
Owner

Choose a reason for hiding this comment

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

I guess I was thinking about a case somewhat related to your other comment in which, for example, someone upgrades the script to a version that adjusts the primary iterations value, then changes the cached iterations value for an account before they've logged in, perhaps thinking this will increase security (when in actual fact that would cause a decryption failure, and they should instead just log in to the account to trigger rotation). But that's a pretty long shot, and they could also just re-authorise the account regardless. So on balance I think the current approach is probably fine, and ITERATIONS can just be updated as and when the recommendation changes.

As an aside, since the proxy requires Python 3.6 or later, and underscore notation was added in that version (via PEP 515) I think we might as well use the clearer values of 870_000 and 100_000 directly here.

emailproxy.py Outdated
token_salt = config.get(username, 'token_salt', fallback=None)
if token_salt:
try:
self._salt = base64.b64decode(token_salt.encode('ascii')) # catch incorrect third-party proxy guide
Copy link
Owner

Choose a reason for hiding this comment

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

What's the reason for switching to ascii here?

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 base64 alphabet is always going to be ascii so if it doesn't encode then the decoding is not going to work. If for some reason (unlikely) the config returns unicode that can't be encoded as 'utf-8' this would have still broken before.

emailproxy.py Outdated

@property
def salt(self):
return base64.b64encode(self._salt).decode('ascii')
Copy link
Owner

Choose a reason for hiding this comment

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

As above – what's the reason for switching to ascii here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This one is guaranteed to always work since the base 64 alphabet is ascii

iterations = self.LEGACY_ITERATIONS

# the first fernet is the primary fernet so sort the iterations count descending
self._iterations_options = sorted({self.ITERATIONS, iterations, self.LEGACY_ITERATIONS}, reverse=True)
Copy link
Owner

Choose a reason for hiding this comment

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

I'm curious – I'd always do self.ITERATIONS + iterations + self.LEGACY_ITERATIONS here. What's the reason for {self.ITERATIONS, iterations, self.LEGACY_ITERATIONS}?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

since it's a set it will drop out any duplicates since iterations should be self.ITERATIONS

Copy link
Owner

Choose a reason for hiding this comment

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

Sorry, I had in my head that these values were lists after making the first comment above (and hence this would not work). Ignore this one!

@terencehonles
Copy link
Contributor Author

terencehonles commented Oct 17, 2023

One thing that may not be obvious is that you can actually set the token_iteration in the config to a value larger than what the script provides. As is, as long as the original iterations value is either Cryptographer.ITERATIONS or Cryptographer.LEGACY_ITERATIONS then they will be tried as fallbacks and the value in the config would be re-written since it would be the primary fernet.

This could support a previous or future iterations value by allowing the Cryptographer to have a fallback_iterations/new_iterations passed to its constructor, and one could rotate the config manually but even that could be moved to its own function to allow a user to easily force higher values and move between them. This change seems a bit further out of scope of this original PR, but shows some future opportunities for improvement.

@simonrob
Copy link
Owner

I edited a few comments and removed an unnecessary ValueError check (if fallback is specified this doesn't happen). I think this is good to go?

@simonrob simonrob merged commit 1c067dd into simonrob:main Oct 22, 2023
simonrob added a commit that referenced this pull request Oct 22, 2023
# for free to join this conversation on GitHub. Already have an account? # to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants