Skip to content

Commit f582366

Browse files
committed
update cryptographer to support rotating secrets
1 parent 4ed058e commit f582366

File tree

2 files changed

+93
-40
lines changed

2 files changed

+93
-40
lines changed

emailproxy.py

Lines changed: 92 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@
5050
import asyncore
5151

5252
# for encrypting/decrypting the locally-stored credentials
53-
from cryptography.fernet import Fernet, InvalidToken
53+
from cryptography.fernet import Fernet, InvalidToken, MultiFernet
5454
from cryptography.hazmat.backends import default_backend
5555
from cryptography.hazmat.primitives import hashes
5656
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
@@ -461,8 +461,8 @@ class AppConfig:
461461
_PARSER_LOCK = threading.Lock()
462462

463463
# note: removing the unencrypted version of `client_secret_encrypted` is not automatic with --cache-store (see docs)
464-
_CACHED_OPTION_KEYS = ['token_salt', 'access_token', 'access_token_expiry', 'refresh_token', 'last_activity',
465-
'client_secret_encrypted']
464+
_CACHED_OPTION_KEYS = ['access_token', 'access_token_expiry', 'client_secret_encrypted', 'last_activity',
465+
'refresh_token', 'token_salt', 'token_iterations']
466466

467467
# additional cache stores may be implemented by extending CacheStore and adding a prefix entry in this dict
468468
_EXTERNAL_CACHE_STORES = {'aws:': AWSSecretsManagerCacheStore}
@@ -570,34 +570,82 @@ def _save_cache(cache_store_identifier, output_config_parser):
570570
Log.error('Error saving state to cache store file at', cache_store_identifier, '- is the file writable?')
571571

572572

573-
class OAuth2Helper:
574-
class TokenRefreshError(Exception):
575-
pass
573+
class Cryptographer:
574+
ITERATIONS = 870000 # taken from cryptography's suggestion of using Django's defaults
575+
LEGACY_ITERATIONS = 100000
576576

577-
@staticmethod
578-
def get_encryption_info(config, username, password):
579-
"""Returns a tuple of the fernet encrypter/decrypter and the random salt used in base64. This fernet is used to
580-
store sensitive information for this account, including any stored tokens."""
581-
token_salt = config.get(username, 'token_salt', fallback=None)
582-
decoded_salt = None
577+
def __init__(self, config, username, password):
578+
"""Creates a cryptographer which allows encrypting and decrypting sensitive information for this account,
579+
including any stored tokens."""
580+
self._salt = None
583581

584582
# try to base64 decode the existing salt
583+
token_salt = config.get(username, 'token_salt', fallback=None)
585584
if token_salt:
586585
try:
587-
decoded_salt = base64.b64decode(token_salt.encode('ascii')) # catch incorrect third-party proxy guide
586+
self._salt = base64.b64decode(token_salt.encode('ascii')) # catch incorrect third-party proxy guide
588587
except (binascii.Error, UnicodeError):
589588
Log.info('%s: Invalid `token_salt` value found in config file entry for account %s - this value is not '
590589
'intended to be manually created; generating new `token_salt`' % (APP_NAME, username))
591590

592591
# generate a new salt if the salt cannot be decoded or this is the initial run
593-
if not decoded_salt:
594-
decoded_salt = os.urandom(16)
595-
token_salt = base64.b64encode(decoded_salt).decode('ascii')
592+
if not self._salt:
593+
self._salt = os.urandom(16)
596594

597-
# generate encrypter/decrypter based on password and random salt
598-
key_derivation_function = PBKDF2HMAC(algorithm=hashes.SHA256(), length=32, salt=decoded_salt, iterations=100000,
599-
backend=default_backend())
600-
return (Fernet(base64.urlsafe_b64encode(key_derivation_function.derive(password.encode('utf-8')))), token_salt)
595+
# try to read the user configured token iterations
596+
try:
597+
iterations = int(config.get(username, 'token_iterations', fallback=self.LEGACY_ITERATIONS))
598+
except ValueError:
599+
iterations = self.LEGACY_ITERATIONS
600+
601+
# the first fernet is the primary fernet so sort the iterations count descending
602+
self._iterations_options = sorted({self.ITERATIONS, iterations, self.LEGACY_ITERATIONS}, reverse=True)
603+
604+
# generate encrypter/decrypter based on the password and a random salt
605+
password_bytes = password.encode('utf-8')
606+
self._fernets = [
607+
Fernet(base64.urlsafe_b64encode(
608+
PBKDF2HMAC(algorithm=hashes.SHA256(), length=32, salt=self._salt, iterations=iterations,
609+
backend=default_backend()).derive(password_bytes)))
610+
for iterations in self._iterations_options
611+
]
612+
self.fernet = MultiFernet(self._fernets)
613+
614+
@property
615+
def salt(self):
616+
return base64.b64encode(self._salt).decode('ascii')
617+
618+
@property
619+
def iterations(self):
620+
return str(self._iterations_options[0])
621+
622+
def encrypt(self, value):
623+
return self.fernet.encrypt(value.encode('utf-8')).decode('utf-8')
624+
625+
def decrypt(self, value):
626+
return self.fernet.decrypt(value.encode('utf-8')).decode('utf-8')
627+
628+
def requires_rotation(self, value):
629+
try:
630+
# if the first fernet works, everything is up to date
631+
self._fernets[0].decrypt(value.encode('utf-8'))
632+
return False
633+
except InvalidToken:
634+
try:
635+
# check to see if any fernet can decrypt the value
636+
self.decrypt(value)
637+
except InvalidToken:
638+
return False
639+
640+
return True
641+
642+
def rotate(self, value):
643+
return self.fernet.rotate(value.encode('utf-8')).decode('utf-8')
644+
645+
646+
class OAuth2Helper:
647+
class TokenRefreshError(Exception):
648+
pass
601649

602650
@staticmethod
603651
def get_oauth2_credentials(username, password, reload_remote_accounts=True):
@@ -666,25 +714,37 @@ def get_account_with_catch_all_fallback(option):
666714
AppConfig.unload()
667715
return OAuth2Helper.get_oauth2_credentials(username, password, reload_remote_accounts=False)
668716

669-
fernet, token_salt = OAuth2Helper.get_encryption_info(config, username, password)
717+
cryptographer = Cryptographer(config, username, password)
718+
rotatable_values = {
719+
'access_token': access_token,
720+
'client_secret_encrypted': client_secret_encrypted,
721+
'refresh_token': refresh_token,
722+
}
723+
if any(value and cryptographer.requires_rotation(value) for value in rotatable_values.values()):
724+
Log.info('Rotating stored secrets with new cryptographic parameters.')
725+
for key, value in rotatable_values.items():
726+
if value:
727+
config.set(username, key, cryptographer.rotate(value))
728+
729+
config.set(username, 'token_iterations', cryptographer.iterations)
730+
AppConfig.save()
670731

671732
try:
672733
# if both secret values are present we use the unencrypted version (as it may have been user-edited)
673734
if client_secret_encrypted and not client_secret:
674-
client_secret = OAuth2Helper.decrypt(fernet, client_secret_encrypted)
735+
client_secret = cryptographer.decrypt(client_secret_encrypted)
675736

676737
if access_token or refresh_token: # if possible, refresh the existing token(s)
677738
if not access_token or access_token_expiry - current_time < TOKEN_EXPIRY_MARGIN:
678739
if refresh_token:
679740
response = OAuth2Helper.refresh_oauth2_access_token(token_url, client_id, client_secret,
680-
OAuth2Helper.decrypt(fernet, refresh_token))
741+
cryptographer.decrypt(refresh_token))
681742

682743
access_token = response['access_token']
683-
config.set(username, 'access_token', OAuth2Helper.encrypt(fernet, access_token))
744+
config.set(username, 'access_token', cryptographer.encrypt(access_token))
684745
config.set(username, 'access_token_expiry', str(current_time + response['expires_in']))
685746
if 'refresh_token' in response:
686-
config.set(username, 'refresh_token',
687-
OAuth2Helper.encrypt(fernet, response['refresh_token']))
747+
config.set(username, 'refresh_token', cryptographer.encrypt(response['refresh_token']))
688748
AppConfig.save()
689749

690750
else:
@@ -695,7 +755,7 @@ def get_account_with_catch_all_fallback(option):
695755
# very infrequently, we don't add the extra complexity for just 10 extra minutes of token life)
696756
access_token = None # avoid trying invalid (or soon to be) tokens
697757
else:
698-
access_token = OAuth2Helper.decrypt(fernet, access_token)
758+
access_token = cryptographer.decrypt(access_token)
699759

700760
if not access_token:
701761
auth_result = None
@@ -722,12 +782,13 @@ def get_account_with_catch_all_fallback(option):
722782
if username not in config.sections():
723783
config.add_section(username) # in catch-all mode the section may not yet exist
724784
REQUEST_QUEUE.put(MENU_UPDATE) # make sure the menu shows the newly-added account
725-
config.set(username, 'token_salt', token_salt)
726-
config.set(username, 'access_token', OAuth2Helper.encrypt(fernet, access_token))
785+
config.set(username, 'token_salt', cryptographer.salt)
786+
config.set(username, 'token_iterations', cryptographer.iterations)
787+
config.set(username, 'access_token', cryptographer.encrypt(access_token))
727788
config.set(username, 'access_token_expiry', str(current_time + response['expires_in']))
728789

729790
if 'refresh_token' in response:
730-
config.set(username, 'refresh_token', OAuth2Helper.encrypt(fernet, response['refresh_token']))
791+
config.set(username, 'refresh_token', cryptographer.encrypt(response['refresh_token']))
731792
elif permission_url: # ignore this situation with client credentials flow - it is expected
732793
Log.info('Warning: no refresh token returned for', username, '- you will need to re-authenticate',
733794
'each time the access token expires (does your `oauth2_scope` value allow `offline` use?)')
@@ -736,7 +797,7 @@ def get_account_with_catch_all_fallback(option):
736797
if client_secret:
737798
# note: save to the `username` entry even if `user_domain` exists, avoiding conflicts when using
738799
# incompatible `encrypt_client_secret_on_first_use` and `allow_catch_all_accounts` options
739-
config.set(username, 'client_secret_encrypted', OAuth2Helper.encrypt(fernet, client_secret))
800+
config.set(username, 'client_secret_encrypted', cryptographer.encrypt(client_secret))
740801
config.remove_option(username, 'client_secret')
741802

742803
AppConfig.save()
@@ -788,14 +849,6 @@ def get_account_with_catch_all_fallback(option):
788849
return False, '%s: Login failed for account %s - please check your internet connection and retry' % (
789850
APP_NAME, username)
790851

791-
@staticmethod
792-
def encrypt(cryptographer, byte_input):
793-
return cryptographer.encrypt(byte_input.encode('utf-8')).decode('utf-8')
794-
795-
@staticmethod
796-
def decrypt(cryptographer, byte_input):
797-
return cryptographer.decrypt(byte_input.encode('utf-8')).decode('utf-8')
798-
799852
@staticmethod
800853
def oauth2_url_escape(text):
801854
return urllib.parse.quote(text, safe='~-._') # see https://tools.ietf.org/html/rfc3986#section-2.3

requirements-no-gui.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# this file contains the proxy's core dependencies beyond inbuilt python packages
22
# note that to use this file instead of the default requirements.txt you *must* pass the `--no-gui` option when starting
33
# the proxy - see the script's readme for further details
4-
cryptography
4+
cryptography>=2.2
55

66
# provide the previously standard library module `asyncore`, removed in Python 3.12 (https://peps.python.org/pep-0594/)
77
pyasyncore; python_version >= '3.12'

0 commit comments

Comments
 (0)