50
50
import asyncore
51
51
52
52
# for encrypting/decrypting the locally-stored credentials
53
- from cryptography .fernet import Fernet , InvalidToken
53
+ from cryptography .fernet import Fernet , InvalidToken , MultiFernet
54
54
from cryptography .hazmat .backends import default_backend
55
55
from cryptography .hazmat .primitives import hashes
56
56
from cryptography .hazmat .primitives .kdf .pbkdf2 import PBKDF2HMAC
@@ -461,8 +461,8 @@ class AppConfig:
461
461
_PARSER_LOCK = threading .Lock ()
462
462
463
463
# 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 ' ]
466
466
467
467
# additional cache stores may be implemented by extending CacheStore and adding a prefix entry in this dict
468
468
_EXTERNAL_CACHE_STORES = {'aws:' : AWSSecretsManagerCacheStore }
@@ -570,34 +570,82 @@ def _save_cache(cache_store_identifier, output_config_parser):
570
570
Log .error ('Error saving state to cache store file at' , cache_store_identifier , '- is the file writable?' )
571
571
572
572
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
576
576
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
583
581
584
582
# try to base64 decode the existing salt
583
+ token_salt = config .get (username , 'token_salt' , fallback = None )
585
584
if token_salt :
586
585
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
588
587
except (binascii .Error , UnicodeError ):
589
588
Log .info ('%s: Invalid `token_salt` value found in config file entry for account %s - this value is not '
590
589
'intended to be manually created; generating new `token_salt`' % (APP_NAME , username ))
591
590
592
591
# 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 )
596
594
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
601
649
602
650
@staticmethod
603
651
def get_oauth2_credentials (username , password , reload_remote_accounts = True ):
@@ -666,25 +714,37 @@ def get_account_with_catch_all_fallback(option):
666
714
AppConfig .unload ()
667
715
return OAuth2Helper .get_oauth2_credentials (username , password , reload_remote_accounts = False )
668
716
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 ()
670
731
671
732
try :
672
733
# if both secret values are present we use the unencrypted version (as it may have been user-edited)
673
734
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 )
675
736
676
737
if access_token or refresh_token : # if possible, refresh the existing token(s)
677
738
if not access_token or access_token_expiry - current_time < TOKEN_EXPIRY_MARGIN :
678
739
if refresh_token :
679
740
response = OAuth2Helper .refresh_oauth2_access_token (token_url , client_id , client_secret ,
680
- OAuth2Helper .decrypt (fernet , refresh_token ))
741
+ cryptographer .decrypt (refresh_token ))
681
742
682
743
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 ))
684
745
config .set (username , 'access_token_expiry' , str (current_time + response ['expires_in' ]))
685
746
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' ]))
688
748
AppConfig .save ()
689
749
690
750
else :
@@ -695,7 +755,7 @@ def get_account_with_catch_all_fallback(option):
695
755
# very infrequently, we don't add the extra complexity for just 10 extra minutes of token life)
696
756
access_token = None # avoid trying invalid (or soon to be) tokens
697
757
else :
698
- access_token = OAuth2Helper .decrypt (fernet , access_token )
758
+ access_token = cryptographer .decrypt (access_token )
699
759
700
760
if not access_token :
701
761
auth_result = None
@@ -722,12 +782,13 @@ def get_account_with_catch_all_fallback(option):
722
782
if username not in config .sections ():
723
783
config .add_section (username ) # in catch-all mode the section may not yet exist
724
784
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 ))
727
788
config .set (username , 'access_token_expiry' , str (current_time + response ['expires_in' ]))
728
789
729
790
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' ]))
731
792
elif permission_url : # ignore this situation with client credentials flow - it is expected
732
793
Log .info ('Warning: no refresh token returned for' , username , '- you will need to re-authenticate' ,
733
794
'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):
736
797
if client_secret :
737
798
# note: save to the `username` entry even if `user_domain` exists, avoiding conflicts when using
738
799
# 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 ))
740
801
config .remove_option (username , 'client_secret' )
741
802
742
803
AppConfig .save ()
@@ -788,14 +849,6 @@ def get_account_with_catch_all_fallback(option):
788
849
return False , '%s: Login failed for account %s - please check your internet connection and retry' % (
789
850
APP_NAME , username )
790
851
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
-
799
852
@staticmethod
800
853
def oauth2_url_escape (text ):
801
854
return urllib .parse .quote (text , safe = '~-._' ) # see https://tools.ietf.org/html/rfc3986#section-2.3
0 commit comments