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

1""" 

2This file deals with the 2fas encryption and keyring integration. 

3""" 

4 

5import base64 

6import getpass 

7import hashlib 

8import json 

9import logging 

10import time 

11import warnings 

12from pathlib import Path 

13from typing import Any, Optional 

14 

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 

22 

23from ._types import AnyDict, TwoFactorAuthDetails, into_class 

24 

25# Suppress keyring warnings 

26keyring_logger = logging.getLogger("keyring") 

27keyring_logger.setLevel(logging.ERROR) # Set the logging level to ERROR for keyring logger 

28 

29 

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 

41 

42 

43def decrypt(encrypted: str, passphrase: str) -> list[TwoFactorAuthDetails]: 

44 """ 

45 

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 

55 

56 

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() 

64 

65 

66PREFIX = "2fas:" 

67 

68 

69class KeyringManager: 

70 appname: str = "" 

71 tmp_file = Path("/tmp") / ".2fas" 

72 

73 def __init__(self) -> None: 

74 self._init() 

75 

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 

80 

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) 

89 

90 @classmethod 

91 def _retrieve_credentials(cls, filename: str, appname: str) -> Optional[str]: 

92 return keyring.get_password(appname, hash_string(filename)) 

93 

94 def retrieve_credentials(self, filename: str) -> Optional[str]: 

95 return self._retrieve_credentials(filename, self.appname) 

96 

97 @classmethod 

98 def _save_credentials(cls, filename: str, passphrase: str, appname: str) -> None: 

99 keyring.set_password(appname, hash_string(filename), passphrase) 

100 

101 def save_credentials(self, filename: str) -> str: 

102 passphrase = getpass.getpass(f"Passphrase for '{filename}'? ") 

103 self._save_credentials(filename, passphrase, self.appname) 

104 

105 return passphrase 

106 

107 @classmethod 

108 def _delete_credentials(cls, filename: str, appname: str) -> None: 

109 keyring.delete_password(appname, hash_string(filename)) 

110 

111 def delete_credentials(self, filename: str) -> None: 

112 self._delete_credentials(filename, self.appname) 

113 

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 

120 

121 collection = kr.get_preferred_collection() 

122 

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 ) 

135 

136 def cleanup_keyring(self) -> None: 

137 self._cleanup_keyring(self.appname) 

138 

139 

140keyring_manager = KeyringManager()