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

1""" 

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

3""" 

4 

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 

16 

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 

26 

27from ._types import AnyDict, TwoFactorAuthDetails, into_class 

28 

29if typing.TYPE_CHECKING: # pragma: no cover 

30 from secretstorage import Item as SecretStorageItem 

31 

32# Suppress keyring warnings 

33keyring_logger = logging.getLogger("keyring") 

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

35 

36 

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 

48 

49 

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

51 """ 

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

53 

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 

63 

64 

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

72 

73 

74PREFIX = "2fas:" 

75 

76 

77class KeyringManagerProtocol(typing.Protocol): 

78 """ 

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

80 """ 

81 

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

83 """ 

84 Get the saved passphrase for a specific file. 

85 """ 

86 

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

88 """ 

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

90 """ 

91 

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

93 """ 

94 Remove a stored passphrase for a file. 

95 """ 

96 

97 def cleanup_keyring(self) -> int: 

98 """ 

99 Remove all old items from the keyring. 

100 """ 

101 

102 

103class DummyKeyringManager(KeyringManagerProtocol): 

104 """ 

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

106 """ 

107 

108 __cache: dict[str, str] 

109 

110 def __init__(self) -> None: 

111 """ 

112 Setup the memory cache. 

113 """ 

114 self.__cache = {} 

115 

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) 

121 

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 

129 

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 

136 

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 

143 

144 

145class KeyringManager(KeyringManagerProtocol): 

146 """ 

147 Makes working with the keyring a bit easier. 

148 

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

153 

154 appname: str = "" 

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

156 

157 def __init__(self) -> None: 

158 """ 

159 See _init. 

160 """ 

161 self._init() 

162 

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 

169 

170 kr = keyring.get_keyring() 

171 

172 if isinstance(kr, keyring.backends.fail.Keyring): # pragma: no cover 

173 return DummyKeyringManager() 

174 

175 return cls() 

176 

177 def _init(self) -> None: 

178 """ 

179 Setup for a new instance. 

180 

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 

185 

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) 

194 

195 @classmethod 

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

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

198 

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 

208 

209 @classmethod 

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

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

212 

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) 

222 

223 return passphrase 

224 

225 @classmethod 

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

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

228 

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) 

237 

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) 

244 

245 @classmethod 

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

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

248 

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 

252 

253 collection = kr.get_preferred_collection() 

254 

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 ] 

264 

265 for item in old: 

266 cls._delete_item(item) 

267 

268 # get old 2fas: keyring items: 

269 return len(old) 

270 

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 

280 

281 

282keyring_manager = KeyringManager.or_dummy()