Coverage for src/lib2fas/_security.py: 100%
110 statements
« prev ^ index » next coverage.py v7.4.1, created at 2025-01-12 17:31 +0100
« prev ^ index » next coverage.py v7.4.1, created at 2025-01-12 17:31 +0100
1"""
2This file deals with the 2fas encryption and keyring integration.
3"""
5import base64
6import getpass
7import hashlib
8import logging
9import tempfile
10import time
11import typing
12import warnings
13from pathlib import Path
14from typing import Any, Optional
16import cryptography.exceptions
17import keyring
18import keyring.backends.SecretService
19import pyjson5
20from cryptography.hazmat.primitives.ciphers.aead import AESGCM
21from cryptography.hazmat.primitives.hashes import SHA256
22from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
23from keyring.backend import KeyringBackend
25from ._types import AnyDict, TwoFactorAuthDetails, into_class
27if typing.TYPE_CHECKING: # pragma: no cover
28 from secretstorage import Item as SecretStorageItem
30# Suppress keyring warnings
31keyring_logger = logging.getLogger("keyring")
32keyring_logger.setLevel(logging.ERROR) # Set the logging level to ERROR for keyring logger
35def _decrypt(encrypted: str, passphrase: str) -> list[AnyDict]:
36 # thanks https://github.com/wodny/decrypt-2fas-backup/blob/master/decrypt-2fas-backup.py
37 credentials_enc, pbkdf2_salt, nonce = map(base64.b64decode, encrypted.split(":"))
38 kdf = PBKDF2HMAC(algorithm=SHA256(), length=32, salt=pbkdf2_salt, iterations=10000)
39 key = kdf.derive(passphrase.encode())
40 aesgcm = AESGCM(key)
41 credentials_dec = aesgcm.decrypt(nonce, credentials_enc, None)
42 dec = pyjson5.loads(credentials_dec.decode()) # type: list[AnyDict]
43 if not isinstance(dec, list): # pragma: no cover
44 raise TypeError("Unexpected data structure in input file.")
45 return dec
48def decrypt(encrypted: str, passphrase: str) -> list[TwoFactorAuthDetails]:
49 """
50 Decrypt the 'servicesEncrypted' block with a passphrase into a list of TwoFactorAuthDetails instances.
52 Raises:
53 PermissionError
54 """
55 try:
56 dicts = _decrypt(encrypted, passphrase)
57 return into_class(dicts, TwoFactorAuthDetails)
58 except cryptography.exceptions.InvalidTag as e:
59 # wrong passphrase!
60 raise PermissionError("Invalid passphrase for file.") from e
63def hash_string(data: Any) -> str:
64 """
65 Hashes a string using SHA-256.
66 """
67 sha256 = hashlib.sha256()
68 sha256.update(str(data).encode())
69 return sha256.hexdigest()
72PREFIX = "2fas:"
75class KeyringManagerProtocol(typing.Protocol):
76 """
77 Abstract protocol which defines the methods the real and dummy KeyringManager classes must have.
78 """
80 def retrieve_credentials(self, filename: str) -> Optional[str]:
81 """
82 Get the saved passphrase for a specific file.
83 """
85 def save_credentials(self, filename: str) -> str:
86 """
87 Query the user for a passphrase and store it in the keyring.
88 """
90 def delete_credentials(self, filename: str) -> None:
91 """
92 Remove a stored passphrase for a file.
93 """
95 def cleanup_keyring(self) -> None:
96 """
97 Remove all old items from the keyring.
98 """
101class DummyKeyringManager(KeyringManagerProtocol):
102 """
103 Fallback Keyring Manager which stores the passphrase in memory instead of in a keyring.
104 """
106 __cache: dict[str, str]
108 def __init__(self) -> None:
109 """
110 Setup the memory cache.
111 """
112 self.__cache = {}
114 def retrieve_credentials(self, filename: str) -> Optional[str]:
115 """
116 Get the saved passphrase for a specific file.
117 """
118 return self.__cache.get(filename, None)
120 def save_credentials(self, filename: str) -> str:
121 """
122 Query the user for a passphrase and store it in the keyring.
123 """
124 value = getpass.getpass(f"Passphrase for '{filename}'? ")
125 self.__cache[filename] = value
126 return value
128 def delete_credentials(self, filename: str) -> None:
129 """
130 Remove a stored passphrase for a file.
131 """
132 self.__cache.pop(filename, None)
133 return None
135 def cleanup_keyring(self) -> None:
136 """
137 Remove all old items from the keyring.
138 """
139 # self.__cache.clear() # disable to prevent double prompting
140 return None
143class KeyringManager(KeyringManagerProtocol):
144 """
145 Makes working with the keyring a bit easier.
147 Stores passphrases for encrypted .2fas files in the keyring.
148 When the user logs out, the keyring item is invalidated and the user is asked for the passphrase again.
149 While the user stays logged in, the passphrase is then 'remembered'.
150 """
152 appname: str = ""
153 tmp_file = Path(tempfile.gettempdir()) / ".2fas"
155 def __init__(self) -> None:
156 """
157 See _init.
158 """
159 self._init()
161 @classmethod
162 def or_dummy(cls) -> KeyringManagerProtocol:
163 """
164 Get a KeyringManager if keyring is available, or a DummyKeyringManger otherwise.
165 """
166 import keyring.backends.fail
168 if isinstance(keyring.get_keyring(), keyring.backends.fail.Keyring): # pragma: no cover
169 return DummyKeyringManager()
171 return cls()
173 def _init(self) -> None:
174 """
175 Setup for a new instance.
177 This is used instead of __init__ so you can call init again to set active appname (for pytest)
178 """
179 tmp_file = self.tmp_file
180 # APPNAME is session specific but with global prefix for easy clean up
182 if tmp_file.exists() and (session := tmp_file.read_text()) and session.startswith(PREFIX):
183 # existing session
184 self.appname = session
185 else:
186 # new session!
187 session = hash_string((time.time())) # random enough for this purpose
188 self.appname = f"{PREFIX}{session}"
189 tmp_file.write_text(self.appname)
191 @classmethod
192 def _retrieve_credentials(cls, filename: str, appname: str) -> Optional[str]:
193 return keyring.get_password(appname, hash_string(filename))
195 def retrieve_credentials(self, filename: str) -> Optional[str]:
196 """
197 Get the saved passphrase for a specific file.
198 """
199 return self._retrieve_credentials(filename, self.appname)
201 @classmethod
202 def _save_credentials(cls, filename: str, passphrase: str, appname: str) -> None:
203 keyring.set_password(appname, hash_string(filename), passphrase)
205 def save_credentials(self, filename: str) -> str:
206 """
207 Query the user for a passphrase and store it in the keyring.
208 """
209 passphrase = getpass.getpass(f"Passphrase for '{filename}'? ")
210 self._save_credentials(filename, passphrase, self.appname)
212 return passphrase
214 @classmethod
215 def _delete_credentials(cls, filename: str, appname: str) -> None:
216 keyring.delete_password(appname, hash_string(filename))
218 def delete_credentials(self, filename: str) -> None:
219 """
220 Remove a stored passphrase for a file.
221 """
222 self._delete_credentials(filename, self.appname)
224 @classmethod
225 def _delete_item(cls, item: "SecretStorageItem") -> None:
226 attrs = item.get_attributes()
227 old_appname = attrs["service"]
228 username = attrs["username"]
229 keyring.delete_password(old_appname, username)
231 @classmethod
232 def _cleanup_keyring(cls, appname: str) -> int:
233 kr: keyring.backends.SecretService.Keyring | KeyringBackend = keyring.get_keyring()
235 if not hasattr(kr, "get_preferred_collection"): # pragma: no cover
236 warnings.warn(f"Can't clean up this keyring backend! {type(kr)}", category=RuntimeWarning)
237 return -1
239 collection = kr.get_preferred_collection()
241 old = [
242 item
243 for item in collection.get_all_items()
244 if (
245 service := item.get_attributes().get("service", "")
246 ) # must have a 'service' attribute, otherwise it's unrelated
247 and service.startswith(PREFIX) # must be a 2fas: service, otherwise it's unrelated
248 and service != appname # must not be the currently active session
249 ]
251 for item in old:
252 cls._delete_item(item)
254 # get old 2fas: keyring items:
255 return len(old)
257 def cleanup_keyring(self) -> None:
258 """
259 Remove all old items from the keyring.
260 """
261 self._cleanup_keyring(self.appname)
264keyring_manager = KeyringManager.or_dummy()