Coverage for src/lib2fas/_security.py: 100%
75 statements
« prev ^ index » next coverage.py v7.4.1, created at 2024-01-29 11:26 +0100
« prev ^ index » next coverage.py v7.4.1, created at 2024-01-29 11:26 +0100
1"""
2This file deals with the 2fas encryption and keyring integration.
3"""
5import base64
6import getpass
7import hashlib
8import json
9import logging
10import time
11import warnings
12from pathlib import Path
13from typing import Any, Optional
15import cryptography.exceptions
16import keyring
17import keyring.backends.SecretService
18from cryptography.hazmat.primitives.ciphers.aead import AESGCM
19from cryptography.hazmat.primitives.hashes import SHA256
20from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
21from keyring.backend import KeyringBackend
23from ._types import AnyDict, TwoFactorAuthDetails, into_class
25# Suppress keyring warnings
26keyring_logger = logging.getLogger("keyring")
27keyring_logger.setLevel(logging.ERROR) # Set the logging level to ERROR for keyring logger
30def _decrypt(encrypted: str, passphrase: str) -> list[AnyDict]:
31 # thanks https://github.com/wodny/decrypt-2fas-backup/blob/master/decrypt-2fas-backup.py
32 credentials_enc, pbkdf2_salt, nonce = map(base64.b64decode, encrypted.split(":"))
33 kdf = PBKDF2HMAC(algorithm=SHA256(), length=32, salt=pbkdf2_salt, iterations=10000)
34 key = kdf.derive(passphrase.encode())
35 aesgcm = AESGCM(key)
36 credentials_dec = aesgcm.decrypt(nonce, credentials_enc, None)
37 dec = json.loads(credentials_dec) # type: list[AnyDict]
38 if not isinstance(dec, list): # pragma: no cover
39 raise TypeError("Unexpected data structure in input file.")
40 return dec
43def decrypt(encrypted: str, passphrase: str) -> list[TwoFactorAuthDetails]:
44 """
46 Raises:
47 PermissionError
48 """
49 try:
50 dicts = _decrypt(encrypted, passphrase)
51 return into_class(dicts, TwoFactorAuthDetails)
52 except cryptography.exceptions.InvalidTag as e:
53 # wrong passphrase!
54 raise PermissionError("Invalid passphrase for file.") from e
57def hash_string(data: Any) -> str:
58 """
59 Hashes a string using SHA-256.
60 """
61 sha256 = hashlib.sha256()
62 sha256.update(str(data).encode())
63 return sha256.hexdigest()
66PREFIX = "2fas:"
69class KeyringManager:
70 appname: str = ""
71 tmp_file = Path("/tmp") / ".2fas"
73 def __init__(self) -> None:
74 self._init()
76 def _init(self) -> None:
77 # so you can call init again to set active appname (for pytest)
78 tmp_file = self.tmp_file
79 # APPNAME is session specific but with global prefix for easy clean up
81 if tmp_file.exists() and (session := tmp_file.read_text()) and session.startswith(PREFIX):
82 # existing session
83 self.appname = session
84 else:
85 # new session!
86 session = hash_string((time.time())) # random enough for this purpose
87 self.appname = f"{PREFIX}{session}"
88 tmp_file.write_text(self.appname)
90 @classmethod
91 def _retrieve_credentials(cls, filename: str, appname: str) -> Optional[str]:
92 return keyring.get_password(appname, hash_string(filename))
94 def retrieve_credentials(self, filename: str) -> Optional[str]:
95 return self._retrieve_credentials(filename, self.appname)
97 @classmethod
98 def _save_credentials(cls, filename: str, passphrase: str, appname: str) -> None:
99 keyring.set_password(appname, hash_string(filename), passphrase)
101 def save_credentials(self, filename: str) -> str:
102 passphrase = getpass.getpass(f"Passphrase for '{filename}'? ")
103 self._save_credentials(filename, passphrase, self.appname)
105 return passphrase
107 @classmethod
108 def _delete_credentials(cls, filename: str, appname: str) -> None:
109 keyring.delete_password(appname, hash_string(filename))
111 def delete_credentials(self, filename: str) -> None:
112 self._delete_credentials(filename, self.appname)
114 @classmethod
115 def _cleanup_keyring(cls, appname: str) -> int:
116 kr: keyring.backends.SecretService.Keyring | KeyringBackend = keyring.get_keyring()
117 if not hasattr(kr, "get_preferred_collection"): # pragma: no cover
118 warnings.warn(f"Can't clean up this keyring backend! {type(kr)}", category=RuntimeWarning)
119 return -1
121 collection = kr.get_preferred_collection()
123 # get old 2fas: keyring items:
124 return len(
125 [
126 item
127 for item in collection.get_all_items()
128 if (
129 service := item.get_attributes().get("service", "")
130 ) # must have a 'service' attribute, otherwise it's unrelated
131 and service.startswith(PREFIX) # must be a 2fas: service, otherwise it's unrelated
132 and service != appname # must not be the currently active session
133 ]
134 )
136 def cleanup_keyring(self) -> None:
137 self._cleanup_keyring(self.appname)
140keyring_manager = KeyringManager()