99import abc
1010import os
1111import errno
12+ import hashlib
1213import logging
1314import sys
1415try :
@@ -50,21 +51,52 @@ def _mkdir_p(path):
5051 else :
5152 raise
5253
54+ def _auto_hash (input_string ):
55+ return hashlib .sha256 (input_string .encode ('utf-8' )).hexdigest ()
56+
5357
5458# We do not aim to wrap every os-specific exception.
55- # Here we define only the most common one,
56- # otherwise caller would need to catch os-specific persistence exceptions.
57- class PersistenceNotFound (IOError ): # Use IOError rather than OSError as base,
59+ # Here we standardize only the most common ones,
60+ # otherwise caller would need to catch os-specific underlying exceptions.
61+ class PersistenceError (IOError ): # Use IOError rather than OSError as base,
62+ """The base exception for persistence."""
5863 # because historically an IOError was bubbled up and expected.
5964 # https://github.com/AzureAD/microsoft-authentication-extensions-for-python/blob/0.2.2/msal_extensions/token_cache.py#L38
6065 # Now we want to maintain backward compatibility even when using Python 2.x
6166 # It makes no difference in Python 3.3+ where IOError is an alias of OSError.
67+ def __init__ (self , err_no = None , message = None , location = None ): # pylint: disable=useless-super-delegation
68+ super (PersistenceError , self ).__init__ (err_no , message , location )
69+
70+
71+ class PersistenceNotFound (PersistenceError ):
6272 """This happens when attempting BasePersistence.load() on a non-existent persistence instance"""
6373 def __init__ (self , err_no = None , message = None , location = None ):
6474 super (PersistenceNotFound , self ).__init__ (
65- err_no or errno .ENOENT ,
66- message or "Persistence not found" ,
67- location )
75+ err_no = errno .ENOENT ,
76+ message = message or "Persistence not found" ,
77+ location = location )
78+
79+ class PersistenceEncryptionError (PersistenceError ):
80+ """This could be raised by persistence.save()"""
81+
82+ class PersistenceDecryptionError (PersistenceError ):
83+ """This could be raised by persistence.load()"""
84+
85+
86+ def build_encrypted_persistence (location ):
87+ """Build a suitable encrypted persistence instance based your current OS.
88+
89+ If you do not need encryption, then simply use ``FilePersistence`` constructor.
90+ """
91+ # Does not (yet?) support fallback_to_plaintext flag,
92+ # because the persistence on Windows and macOS do not support built-in trial_run().
93+ if sys .platform .startswith ('win' ):
94+ return FilePersistenceWithDataProtection (location )
95+ if sys .platform .startswith ('darwin' ):
96+ return KeychainPersistence (location )
97+ if sys .platform .startswith ('linux' ):
98+ return LibsecretPersistence (location )
99+ raise RuntimeError ("Unsupported platform: {}" .format (sys .platform )) # pylint: disable=consider-using-f-string
68100
69101
70102class BasePersistence (ABC ):
@@ -101,6 +133,11 @@ def get_location(self):
101133 raise NotImplementedError
102134
103135
136+ def _open (location ):
137+ return os .open (location , os .O_RDWR | os .O_CREAT | os .O_TRUNC , 0o600 )
138+ # The 600 seems no-op on NTFS/Windows, and that is fine
139+
140+
104141class FilePersistence (BasePersistence ):
105142 """A generic persistence, storing data in a plain-text file"""
106143
@@ -113,7 +150,7 @@ def __init__(self, location):
113150 def save (self , content ):
114151 # type: (str) -> None
115152 """Save the content into this persistence"""
116- with open ( self ._location , 'w+' ) as handle : # pylint: disable=unspecified-encoding
153+ with os . fdopen ( _open ( self ._location ) , 'w+' ) as handle :
117154 handle .write (content )
118155
119156 def load (self ):
@@ -168,16 +205,21 @@ def __init__(self, location, entropy=''):
168205
169206 def save (self , content ):
170207 # type: (str) -> None
171- data = self ._dp_agent .protect (content )
172- with open (self ._location , 'wb+' ) as handle :
208+ try :
209+ data = self ._dp_agent .protect (content )
210+ except OSError as exception :
211+ raise PersistenceEncryptionError (
212+ err_no = getattr (exception , "winerror" , None ), # Exists in Python 3 on Windows
213+ message = "Encryption failed: {}. Consider disable encryption." .format (exception ),
214+ )
215+ with os .fdopen (_open (self ._location ), 'wb+' ) as handle :
173216 handle .write (data )
174217
175218 def load (self ):
176219 # type: () -> str
177220 try :
178221 with open (self ._location , 'rb' ) as handle :
179222 data = handle .read ()
180- return self ._dp_agent .unprotect (data )
181223 except EnvironmentError as exp : # EnvironmentError in Py 2.7 works across platform
182224 if exp .errno == errno .ENOENT :
183225 raise PersistenceNotFound (
@@ -190,26 +232,36 @@ def load(self):
190232 "DPAPI error likely caused by file content not previously encrypted. "
191233 "App developer should migrate by calling save(plaintext) first." )
192234 raise
235+ try :
236+ return self ._dp_agent .unprotect (data )
237+ except OSError as exception :
238+ raise PersistenceDecryptionError (
239+ err_no = getattr (exception , "winerror" , None ), # Exists in Python 3 on Windows
240+ message = "Decryption failed: {}. "
241+ "App developer may consider this guidance: "
242+ "https://github.com/AzureAD/microsoft-authentication-extensions-for-python/wiki/PersistenceDecryptionError" # pylint: disable=line-too-long
243+ .format (exception ),
244+ location = self ._location ,
245+ )
193246
194247
195248class KeychainPersistence (BasePersistence ):
196249 """A generic persistence with data stored in,
197250 and protected by native Keychain libraries on OSX"""
198251 is_encrypted = True
199252
200- def __init__ (self , signal_location , service_name , account_name ):
253+ def __init__ (self , signal_location , service_name = None , account_name = None ):
201254 """Initialization could fail due to unsatisfied dependency.
202255
203256 :param signal_location: See :func:`persistence.LibsecretPersistence.__init__`
204257 """
205- if not (service_name and account_name ): # It would hang on OSX
206- raise ValueError ("service_name and account_name are required" )
207258 from .osx import Keychain , KeychainError # pylint: disable=import-outside-toplevel
208259 self ._file_persistence = FilePersistence (signal_location ) # Favor composition
209260 self ._Keychain = Keychain # pylint: disable=invalid-name
210261 self ._KeychainError = KeychainError # pylint: disable=invalid-name
211- self ._service_name = service_name
212- self ._account_name = account_name
262+ default_service_name = "msal-extensions" # This is also our package name
263+ self ._service_name = service_name or default_service_name
264+ self ._account_name = account_name or _auto_hash (signal_location )
213265
214266 def save (self , content ):
215267 with self ._Keychain () as locker :
@@ -247,7 +299,7 @@ class LibsecretPersistence(BasePersistence):
247299 and protected by native libsecret libraries on Linux"""
248300 is_encrypted = True
249301
250- def __init__ (self , signal_location , schema_name , attributes , ** kwargs ):
302+ def __init__ (self , signal_location , schema_name = None , attributes = None , ** kwargs ):
251303 """Initialization could fail due to unsatisfied dependency.
252304
253305 :param string signal_location:
@@ -262,7 +314,8 @@ def __init__(self, signal_location, schema_name, attributes, **kwargs):
262314 from .libsecret import ( # This uncertain import is deferred till runtime
263315 LibSecretAgent , trial_run )
264316 trial_run ()
265- self ._agent = LibSecretAgent (schema_name , attributes , ** kwargs )
317+ self ._agent = LibSecretAgent (
318+ schema_name or _auto_hash (signal_location ), attributes or {}, ** kwargs )
266319 self ._file_persistence = FilePersistence (signal_location ) # Favor composition
267320
268321 def save (self , content ):
0 commit comments