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

1""" 

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

3""" 

4 

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 

15 

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 

24 

25from ._types import AnyDict, TwoFactorAuthDetails, into_class 

26 

27if typing.TYPE_CHECKING: # pragma: no cover 

28 from secretstorage import Item as SecretStorageItem 

29 

30# Suppress keyring warnings 

31keyring_logger = logging.getLogger("keyring") 

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

33 

34 

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 

46 

47 

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

49 """ 

50 Decrypt the 'servicesEncrypted' block with a passphrase into a list of TwoFactorAuthDetails instances. 

51 

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 

61 

62 

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

70 

71 

72PREFIX = "2fas:" 

73 

74 

75class KeyringManagerProtocol(typing.Protocol): 

76 """ 

77 Abstract protocol which defines the methods the real and dummy KeyringManager classes must have. 

78 """ 

79 

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

81 """ 

82 Get the saved passphrase for a specific file. 

83 """ 

84 

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

86 """ 

87 Query the user for a passphrase and store it in the keyring. 

88 """ 

89 

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

91 """ 

92 Remove a stored passphrase for a file. 

93 """ 

94 

95 def cleanup_keyring(self) -> None: 

96 """ 

97 Remove all old items from the keyring. 

98 """ 

99 

100 

101class DummyKeyringManager(KeyringManagerProtocol): 

102 """ 

103 Fallback Keyring Manager which stores the passphrase in memory instead of in a keyring. 

104 """ 

105 

106 __cache: dict[str, str] 

107 

108 def __init__(self) -> None: 

109 """ 

110 Setup the memory cache. 

111 """ 

112 self.__cache = {} 

113 

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) 

119 

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 

127 

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 

134 

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 

141 

142 

143class KeyringManager(KeyringManagerProtocol): 

144 """ 

145 Makes working with the keyring a bit easier. 

146 

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 """ 

151 

152 appname: str = "" 

153 tmp_file = Path(tempfile.gettempdir()) / ".2fas" 

154 

155 def __init__(self) -> None: 

156 """ 

157 See _init. 

158 """ 

159 self._init() 

160 

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 

167 

168 if isinstance(keyring.get_keyring(), keyring.backends.fail.Keyring): # pragma: no cover 

169 return DummyKeyringManager() 

170 

171 return cls() 

172 

173 def _init(self) -> None: 

174 """ 

175 Setup for a new instance. 

176 

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 

181 

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) 

190 

191 @classmethod 

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

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

194 

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) 

200 

201 @classmethod 

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

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

204 

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) 

211 

212 return passphrase 

213 

214 @classmethod 

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

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

217 

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) 

223 

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) 

230 

231 @classmethod 

232 def _cleanup_keyring(cls, appname: str) -> int: 

233 kr: keyring.backends.SecretService.Keyring | KeyringBackend = keyring.get_keyring() 

234 

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 

238 

239 collection = kr.get_preferred_collection() 

240 

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 ] 

250 

251 for item in old: 

252 cls._delete_item(item) 

253 

254 # get old 2fas: keyring items: 

255 return len(old) 

256 

257 def cleanup_keyring(self) -> None: 

258 """ 

259 Remove all old items from the keyring. 

260 """ 

261 self._cleanup_keyring(self.appname) 

262 

263 

264keyring_manager = KeyringManager.or_dummy()