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