-
-
Notifications
You must be signed in to change notification settings - Fork 113
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
move fernet creation to helper method #198
Conversation
emailproxy.py
Outdated
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)) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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
There was a problem hiding this 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
# check if there was an error getting the encryption info | ||
if not isinstance(fernet, Fernet): | ||
return (False, fernet) |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
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)) |
There was a problem hiding this comment.
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.
2fdde19
to
4ed058e
Compare
There was a problem hiding this 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
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)) |
There was a problem hiding this comment.
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
# check if there was an error getting the encryption info | ||
if not isinstance(fernet, Fernet): | ||
return (False, fernet) |
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
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. |
f39767e
to
f582366
Compare
f582366
to
2dc9715
Compare
There was a problem hiding this 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 |
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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') |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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}
?
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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!
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 This could support a previous or future iterations value by allowing the |
I edited a few comments and removed an unnecessary |
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.