freemyid 0.2.0.dev1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. freemyid/__init__.py +2 -0
  2. freemyid/_data/brokers/LICENSE-DATA.md +28 -0
  3. freemyid/_data/brokers/registry.sample.yaml +674 -0
  4. freemyid/_data/brokers/state_laws.yaml +448 -0
  5. freemyid/_data/schema/registry.schema.json +668 -0
  6. freemyid/_data/schema/setup-config.schema.json +538 -0
  7. freemyid/_data/schema/state_laws.schema.json +222 -0
  8. freemyid/cli.py +725 -0
  9. freemyid/cli_json.py +198 -0
  10. freemyid/config.py +43 -0
  11. freemyid/consent.py +82 -0
  12. freemyid/crypto.py +197 -0
  13. freemyid/doctor.py +374 -0
  14. freemyid/engine/__init__.py +0 -0
  15. freemyid/engine/_dev/__init__.py +101 -0
  16. freemyid/engine/_dev/devrun.py +101 -0
  17. freemyid/engine/_dev/inbox/devauto.eml +7 -0
  18. freemyid/engine/_dev/registry.yaml +106 -0
  19. freemyid/engine/_dev/site/devauto.example/confirm.html +1 -0
  20. freemyid/engine/_dev/site/devauto.example/name/jane-testperson/il.html +17 -0
  21. freemyid/engine/_dev/site/devauto.example/name/jane-testperson/nc.html +4 -0
  22. freemyid/engine/_dev/site/devauto.example/name/jane-testperson/oh.html +4 -0
  23. freemyid/engine/_dev/site/devauto.example/optout/done.html +1 -0
  24. freemyid/engine/_dev/site/devauto.example/optout.html +6 -0
  25. freemyid/engine/_dev/site/devblocked.example/name/jane-testperson/il.html +3 -0
  26. freemyid/engine/_dev/site/devblocked.example/optout.html +3 -0
  27. freemyid/engine/_dev/site/devfill.example/ajax.html +20 -0
  28. freemyid/engine/_dev/site/devfill.example/aria.html +16 -0
  29. freemyid/engine/_dev/site/devfill.example/consent.html +20 -0
  30. freemyid/engine/_dev/site/devfill.example/done.html +3 -0
  31. freemyid/engine/_dev/site/devfill.example/empty.html +1 -0
  32. freemyid/engine/_dev/site/devfill.example/fullname.html +12 -0
  33. freemyid/engine/_dev/site/devfill.example/labels.html +32 -0
  34. freemyid/engine/_dev/site/devfill.example/late.html +22 -0
  35. freemyid/engine/_dev/site/devfind.example/hostile.html +12 -0
  36. freemyid/engine/_dev/site/devfind.example/p/jane-testperson-shelbyville-il.html +6 -0
  37. freemyid/engine/_dev/site/devfind.example/p/jane-testperson-springfield-il.html +6 -0
  38. freemyid/engine/_dev/site/devfind.example/results.html +11 -0
  39. freemyid/engine/_dev/site/devfind.example/search.html +16 -0
  40. freemyid/engine/_dev/site/devweb.example/remove.html +1 -0
  41. freemyid/engine/browser.py +430 -0
  42. freemyid/engine/fill_for_me.py +630 -0
  43. freemyid/engine/filler.py +523 -0
  44. freemyid/engine/handoff.py +237 -0
  45. freemyid/engine/letters.py +125 -0
  46. freemyid/engine/listing_finder.py +538 -0
  47. freemyid/engine/mail.py +406 -0
  48. freemyid/engine/mail_tls.py +198 -0
  49. freemyid/engine/matcher.py +208 -0
  50. freemyid/engine/outbox.py +421 -0
  51. freemyid/engine/plugins.py +68 -0
  52. freemyid/engine/runner.py +791 -0
  53. freemyid/engine/tiers.py +107 -0
  54. freemyid/interfaces.py +164 -0
  55. freemyid/logging_setup.py +152 -0
  56. freemyid/models.py +700 -0
  57. freemyid/netpolicy.py +145 -0
  58. freemyid/notify.py +58 -0
  59. freemyid/paths.py +83 -0
  60. freemyid/registry.py +275 -0
  61. freemyid/scheduler/__init__.py +203 -0
  62. freemyid/scheduler/render.py +91 -0
  63. freemyid/secret_backends.py +562 -0
  64. freemyid/secrets.py +25 -0
  65. freemyid/setup_config.py +311 -0
  66. freemyid/state.py +151 -0
  67. freemyid/storage/__init__.py +3 -0
  68. freemyid/storage/db.py +394 -0
  69. freemyid/submitters/__init__.py +0 -0
  70. freemyid/submitters/assisted.py +171 -0
  71. freemyid/submitters/base.py +119 -0
  72. freemyid/submitters/email.py +40 -0
  73. freemyid/submitters/web_form.py +276 -0
  74. freemyid/templates/_identity.txt.j2 +19 -0
  75. freemyid/templates/_signature.txt.j2 +7 -0
  76. freemyid/templates/ccpa.txt.j2 +16 -0
  77. freemyid/templates/follow_up.txt.j2 +15 -0
  78. freemyid/templates/generic_removal.txt.j2 +12 -0
  79. freemyid/templates/legal_demand.txt.j2 +20 -0
  80. freemyid/templates/phone_script.txt.j2 +25 -0
  81. freemyid/templates/postal_letter.txt.j2 +20 -0
  82. freemyid/templates/state_privacy.txt.j2 +17 -0
  83. freemyid/wizard/__init__.py +0 -0
  84. freemyid/wizard/dashboard.py +401 -0
  85. freemyid/wizard/desktop.py +188 -0
  86. freemyid/wizard/draft.py +627 -0
  87. freemyid/wizard/firstrun.py +42 -0
  88. freemyid/wizard/flows.py +283 -0
  89. freemyid/wizard/report.py +86 -0
  90. freemyid/wizard/server.py +834 -0
  91. freemyid/wizard/static/__init__.py +0 -0
  92. freemyid/wizard/static/app.js +98 -0
  93. freemyid/wizard/static/freemyid.png +0 -0
  94. freemyid/wizard/static/style.css +85 -0
  95. freemyid/wizard/ui.py +343 -0
  96. freemyid-0.2.0.dev1.dist-info/METADATA +396 -0
  97. freemyid-0.2.0.dev1.dist-info/RECORD +100 -0
  98. freemyid-0.2.0.dev1.dist-info/WHEEL +4 -0
  99. freemyid-0.2.0.dev1.dist-info/entry_points.txt +2 -0
  100. freemyid-0.2.0.dev1.dist-info/licenses/LICENSE +661 -0
@@ -0,0 +1,562 @@
1
+ """Pluggable secrets backends for the data key and mailbox passwords. OWNER: orchestrator.
2
+
3
+ The AES-GCM store format (crypto.Cipher, b"V1" blobs) never changes. A backend only decides
4
+ where the 32-byte data key lives and where small secrets (mailbox passwords) are kept:
5
+
6
+ * ``keyring`` (default, current behaviour): data key and secrets in the OS keychain.
7
+ * ``passphrase``: the data key is wrapped with AES-GCM under a key derived from a
8
+ passphrase with scrypt, in ``FREEMYID_HOME/datakey.wrapped.json``. Secrets go into
9
+ ``FREEMYID_HOME/secrets.enc``, encrypted with the data key. The passphrase comes from
10
+ the file named by ``FREEMYID_PASSPHRASE_FILE`` (owner-only permissions, else refused)
11
+ or an interactive prompt. Never from an environment variable holding the secret.
12
+ * ``systemd-creds``: same wrapped key, passphrase read from
13
+ ``$CREDENTIALS_DIRECTORY/freemyid-passphrase`` (systemd ``LoadCredential=``) for timer runs.
14
+
15
+ Selection: ``secrets_backend`` in config.yaml wins. Otherwise auto: an existing wrapped key
16
+ file means passphrase (or systemd-creds when the credential is present), a usable keychain
17
+ means keyring, and an unusable keychain falls back to passphrase with a plain message.
18
+ Never to plaintext. Passphrases and keys are never logged.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import base64
23
+ import getpass
24
+ import hmac
25
+ import json
26
+ import logging
27
+ import os
28
+ import stat
29
+ import sys
30
+ from collections.abc import Callable
31
+ from dataclasses import dataclass
32
+ from pathlib import Path
33
+ from typing import Protocol
34
+
35
+ import keyring
36
+ import keyring.errors
37
+ from cryptography.exceptions import InvalidTag
38
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
39
+ from cryptography.hazmat.primitives.kdf.scrypt import Scrypt
40
+
41
+ from . import crypto, paths
42
+
43
+ log = logging.getLogger(__name__)
44
+
45
+ BACKENDS = ("keyring", "passphrase", "systemd-creds")
46
+ PASSPHRASE_FILE_ENV = "FREEMYID_PASSPHRASE_FILE"
47
+ CRED_NAME = "freemyid-passphrase"
48
+ WRAP_FILE = "datakey.wrapped.json"
49
+ SECRETS_FILE = "secrets.enc"
50
+ MIN_PASSPHRASE_LEN = 8
51
+ MAX_PASSPHRASE_BYTES = 4096
52
+
53
+ # scrypt cost. 2**17/8/1 is the OWASP minimum (about 128 MiB, ~0.2 s). Stored params are
54
+ # accepted only inside these bounds so a tampered file can't make us burn memory forever
55
+ # or derive with a trivially weak cost.
56
+ SCRYPT_N = 2**17
57
+ SCRYPT_R = 8
58
+ SCRYPT_P = 1
59
+ _N_RANGE = (2**14, 2**20)
60
+ _R_RANGE = (8, 16)
61
+ _P_RANGE = (1, 4)
62
+ _WRAP_AAD = b"freemyid:datakey-wrap:v1"
63
+ _SECRETS_AAD = b"freemyid:secrets:v1"
64
+
65
+ # Data keys unwrapped this process, keyed by (home, wrap-file path). Deriving costs ~0.2 s.
66
+ _key_cache: dict[str, bytes] = {}
67
+
68
+
69
+ def clear_cache() -> None:
70
+ _key_cache.clear()
71
+
72
+
73
+ # ------------------------------------------------------------------------------ protocol
74
+
75
+ class SecretBackend(Protocol):
76
+ name: str
77
+
78
+ def usable(self) -> bool: ...
79
+ def load_data_key(self, create: bool = True) -> bytes: ...
80
+ def store_data_key(self, key: bytes) -> None: ...
81
+ def delete_data_key(self) -> None: ...
82
+ def get_secret(self, service: str, user: str) -> str | None: ...
83
+ def set_secret(self, service: str, user: str, value: str) -> None: ...
84
+ def delete_secret(self, service: str, user: str) -> None: ...
85
+
86
+
87
+ # ------------------------------------------------------------------------------ keyring
88
+
89
+ class KeyringBackend:
90
+ """The OS keychain. Behaviour is exactly the pre-0.2 code path."""
91
+
92
+ name = "keyring"
93
+
94
+ def usable(self) -> bool:
95
+ try:
96
+ crypto._check_backend()
97
+ except crypto.KeyUnavailable:
98
+ return False
99
+ return True
100
+
101
+ def load_data_key(self, create: bool = True) -> bytes:
102
+ if create:
103
+ return crypto._keyring_get_or_create_key()
104
+ try:
105
+ crypto._check_backend()
106
+ stored = keyring.get_password(crypto.SERVICE, crypto.KEY_NAME)
107
+ except keyring.errors.KeyringError as e:
108
+ raise crypto.KeyUnavailable(str(e)) from e
109
+ if stored is None:
110
+ raise crypto.KeyUnavailable("no data key in the OS keychain")
111
+ return crypto._decode_key(stored)
112
+
113
+ def store_data_key(self, key: bytes) -> None:
114
+ try:
115
+ crypto._check_backend()
116
+ keyring.set_password(crypto.SERVICE, crypto.KEY_NAME, base64.b64encode(key).decode())
117
+ except keyring.errors.KeyringError as e:
118
+ raise crypto.KeyUnavailable(str(e)) from e
119
+
120
+ def delete_data_key(self) -> None:
121
+ try:
122
+ keyring.delete_password(crypto.SERVICE, crypto.KEY_NAME)
123
+ except keyring.errors.KeyringError: # missing key or no keychain at all: fine
124
+ pass
125
+
126
+ def get_secret(self, service: str, user: str) -> str | None:
127
+ return keyring.get_password(service, user)
128
+
129
+ def set_secret(self, service: str, user: str, value: str) -> None:
130
+ keyring.set_password(service, user, value)
131
+
132
+ def delete_secret(self, service: str, user: str) -> None:
133
+ try:
134
+ keyring.delete_password(service, user)
135
+ except keyring.errors.PasswordDeleteError:
136
+ pass
137
+
138
+
139
+ # ------------------------------------------------------------------------------ passphrase
140
+
141
+ def _read_secret_file(path: Path, *, allow_group: bool = False) -> str:
142
+ """Read a passphrase file. POSIX: must be a regular file owned by us with no group/other
143
+ access (0600/0400). Checked on the opened fd (no symlink, no swap after the check)."""
144
+ flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
145
+ try:
146
+ fd = os.open(path, flags)
147
+ except OSError as e:
148
+ raise crypto.KeyUnavailable(f"can't open the passphrase file ({e.strerror})") from e
149
+ try:
150
+ st = os.fstat(fd)
151
+ if not stat.S_ISREG(st.st_mode):
152
+ raise crypto.KeyUnavailable("the passphrase file is not a regular file")
153
+ if sys.platform != "win32":
154
+ bad_bits = 0o007 if allow_group else 0o077
155
+ if st.st_mode & bad_bits:
156
+ raise crypto.KeyUnavailable(
157
+ "the passphrase file can be read by other users; "
158
+ f"run: chmod 600 {path}"
159
+ )
160
+ if hasattr(os, "getuid") and st.st_uid != os.getuid():
161
+ raise crypto.KeyUnavailable("the passphrase file is owned by another user")
162
+ data = os.read(fd, MAX_PASSPHRASE_BYTES + 1)
163
+ finally:
164
+ os.close(fd)
165
+ if len(data) > MAX_PASSPHRASE_BYTES:
166
+ raise crypto.KeyUnavailable("the passphrase file is too large")
167
+ try:
168
+ text = data.decode("utf-8")
169
+ except UnicodeDecodeError as e:
170
+ raise crypto.KeyUnavailable("the passphrase file is not UTF-8 text") from e
171
+ if text.endswith("\r\n"):
172
+ text = text[:-2]
173
+ elif text.endswith("\n"):
174
+ text = text[:-1]
175
+ if not text:
176
+ raise crypto.KeyUnavailable("the passphrase file is empty")
177
+ return text
178
+
179
+
180
+ def _tty_prompt(confirm: bool) -> str:
181
+ print("Free My ID needs your passphrase to unlock its data.", file=sys.stderr)
182
+ pw = getpass.getpass("Passphrase: ")
183
+ if confirm:
184
+ if len(pw) < MIN_PASSPHRASE_LEN:
185
+ raise crypto.KeyUnavailable(
186
+ f"the passphrase must be at least {MIN_PASSPHRASE_LEN} characters"
187
+ )
188
+ if not hmac.compare_digest(pw.encode(), getpass.getpass("Type it again: ").encode()):
189
+ raise crypto.KeyUnavailable("the two passphrases did not match")
190
+ return pw
191
+
192
+
193
+ # Replaceable in tests. Called with confirm=True when a new passphrase is being chosen.
194
+ prompt: Callable[[bool], str] = _tty_prompt
195
+
196
+
197
+ def _interactive() -> bool:
198
+ try:
199
+ return sys.stdin is not None and sys.stdin.isatty()
200
+ except (AttributeError, ValueError):
201
+ return False
202
+
203
+
204
+ def _b64(b: bytes) -> str:
205
+ return base64.b64encode(b).decode()
206
+
207
+
208
+ def _unb64(s: object) -> bytes:
209
+ if not isinstance(s, str):
210
+ raise TypeError("expected base64 text")
211
+ return base64.b64decode(s, validate=True)
212
+
213
+
214
+ def _derive(passphrase: str, salt: bytes, n: int, r: int, p: int) -> bytes:
215
+ return Scrypt(salt=salt, length=32, n=n, r=r, p=p).derive(passphrase.encode("utf-8"))
216
+
217
+
218
+ def _atomic_write(path: Path, data: bytes, *, exclusive: bool = False) -> None:
219
+ """Write via a 0600 temp file. exclusive=True never overwrites an existing file
220
+ (first-run race: the loser re-reads the winner's key)."""
221
+ path.parent.mkdir(parents=True, exist_ok=True)
222
+ tmp = path.with_name(f".{path.name}.{os.getpid()}.tmp")
223
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
224
+ try:
225
+ os.write(fd, data)
226
+ os.fsync(fd)
227
+ finally:
228
+ os.close(fd)
229
+ try:
230
+ if exclusive:
231
+ os.link(tmp, path) # raises FileExistsError if someone else won
232
+ else:
233
+ os.replace(tmp, path)
234
+ finally:
235
+ tmp.unlink(missing_ok=True)
236
+
237
+
238
+ class PassphraseBackend:
239
+ """Data key wrapped under scrypt(passphrase); secrets in an encrypted file in home."""
240
+
241
+ name = "passphrase"
242
+
243
+ def __init__(self, passphrase_getter: Callable[[bool], str] | None = None) -> None:
244
+ self._getter = passphrase_getter
245
+
246
+ # --- where the passphrase comes from (systemd-creds overrides this) ----------------
247
+ def _source_available(self) -> bool:
248
+ return bool(os.environ.get(PASSPHRASE_FILE_ENV)) or _interactive()
249
+
250
+ def _passphrase(self, confirm: bool) -> str:
251
+ if self._getter is not None:
252
+ return self._getter(confirm)
253
+ if "FREEMYID_PASSPHRASE" in os.environ:
254
+ # A secret in a plain env var leaks through /proc, ps and crash dumps. Refuse.
255
+ raise crypto.KeyUnavailable(
256
+ "FREEMYID_PASSPHRASE is not supported. Put the passphrase in a file only you "
257
+ f"can read (chmod 600) and set {PASSPHRASE_FILE_ENV} to its path"
258
+ )
259
+ if fp := os.environ.get(PASSPHRASE_FILE_ENV):
260
+ pw = _read_secret_file(Path(fp))
261
+ elif _interactive():
262
+ pw = prompt(confirm)
263
+ else:
264
+ raise crypto.KeyUnavailable(
265
+ "Free My ID needs its passphrase but can't ask for it here. Set "
266
+ f"{PASSPHRASE_FILE_ENV} to a file only you can read, or use systemd credentials"
267
+ )
268
+ if confirm and len(pw) < MIN_PASSPHRASE_LEN:
269
+ raise crypto.KeyUnavailable(
270
+ f"the passphrase must be at least {MIN_PASSPHRASE_LEN} characters"
271
+ )
272
+ return pw
273
+
274
+ # --- files --------------------------------------------------------------------------
275
+ @staticmethod
276
+ def wrap_path() -> Path:
277
+ return paths.home() / WRAP_FILE
278
+
279
+ @staticmethod
280
+ def secrets_path() -> Path:
281
+ return paths.home() / SECRETS_FILE
282
+
283
+ def usable(self) -> bool:
284
+ return self._getter is not None or self._source_available()
285
+
286
+ def _wrap(self, key: bytes, passphrase: str) -> bytes:
287
+ salt, nonce = os.urandom(16), os.urandom(12)
288
+ kek = _derive(passphrase, salt, SCRYPT_N, SCRYPT_R, SCRYPT_P)
289
+ doc = {
290
+ "v": 1, "kdf": "scrypt", "n": SCRYPT_N, "r": SCRYPT_R, "p": SCRYPT_P,
291
+ "salt": _b64(salt), "nonce": _b64(nonce),
292
+ "wrapped": _b64(AESGCM(kek).encrypt(nonce, key, _WRAP_AAD)),
293
+ }
294
+ return json.dumps(doc, indent=1).encode()
295
+
296
+ @staticmethod
297
+ def _unwrap(raw: bytes, passphrase: str) -> bytes:
298
+ try:
299
+ doc = json.loads(raw)
300
+ if doc.get("v") != 1 or doc.get("kdf") != "scrypt":
301
+ raise ValueError("unknown format")
302
+ n, r, p = int(doc["n"]), int(doc["r"]), int(doc["p"])
303
+ if not (_N_RANGE[0] <= n <= _N_RANGE[1] and n & (n - 1) == 0
304
+ and _R_RANGE[0] <= r <= _R_RANGE[1] and _P_RANGE[0] <= p <= _P_RANGE[1]):
305
+ raise ValueError("scrypt parameters out of range")
306
+ salt, nonce, ct = _unb64(doc["salt"]), _unb64(doc["nonce"]), _unb64(doc["wrapped"])
307
+ if len(salt) < 16 or len(nonce) != 12:
308
+ raise ValueError("bad salt/nonce")
309
+ except (ValueError, KeyError, TypeError) as e:
310
+ raise crypto.KeyUnavailable("the wrapped key file is corrupt") from e
311
+ try:
312
+ # GCM tag comparison is constant-time inside OpenSSL; a wrong passphrase and a
313
+ # tampered file fail identically, without revealing which.
314
+ key = AESGCM(_derive(passphrase, salt, n, r, p)).decrypt(nonce, ct, _WRAP_AAD)
315
+ except InvalidTag as e:
316
+ raise crypto.KeyUnavailable("wrong passphrase (or the key file was changed)") from e
317
+ if len(key) != 32:
318
+ raise crypto.KeyUnavailable("the wrapped key has the wrong length")
319
+ return key
320
+
321
+ def load_data_key(self, create: bool = True) -> bytes:
322
+ wp = self.wrap_path()
323
+ cache_id = str(wp.resolve()) if wp.exists() else ""
324
+ if cache_id and cache_id in _key_cache:
325
+ return _key_cache[cache_id]
326
+ if not wp.exists():
327
+ if not create:
328
+ raise crypto.KeyUnavailable("no passphrase-protected key in this data folder")
329
+ pw = self._passphrase(confirm=True)
330
+ key = AESGCM.generate_key(bit_length=256)
331
+ try:
332
+ _atomic_write(wp, self._wrap(key, pw), exclusive=True)
333
+ except FileExistsError:
334
+ pass # another process created it first; fall through and unwrap theirs
335
+ else:
336
+ _key_cache[str(wp.resolve())] = key
337
+ return key
338
+ key = self._unwrap(wp.read_bytes(), pw)
339
+ else:
340
+ key = self._unwrap(wp.read_bytes(), self._passphrase(confirm=False))
341
+ _key_cache[str(wp.resolve())] = key
342
+ return key
343
+
344
+ def store_data_key(self, key: bytes) -> None:
345
+ """Wrap `key` under a passphrase. An existing wrap must already hold the same key."""
346
+ wp = self.wrap_path()
347
+ if wp.exists():
348
+ existing = self._unwrap(wp.read_bytes(), self._passphrase(confirm=False))
349
+ if not hmac.compare_digest(existing, key):
350
+ raise crypto.KeyUnavailable(
351
+ f"{wp.name} already holds a different key; move it aside first"
352
+ )
353
+ else:
354
+ _atomic_write(wp, self._wrap(key, self._passphrase(confirm=True)))
355
+ _key_cache[str(wp.resolve())] = key
356
+
357
+ def delete_data_key(self) -> None:
358
+ wp = self.wrap_path()
359
+ _key_cache.pop(str(wp.resolve()), None)
360
+ wp.unlink(missing_ok=True)
361
+ self.secrets_path().unlink(missing_ok=True)
362
+
363
+ # --- secrets: one encrypted JSON map {service: {user: value}} ----------------------
364
+ def _cipher(self) -> crypto.Cipher:
365
+ return crypto.Cipher(self.load_data_key())
366
+
367
+ def _read_secrets(self) -> dict[str, dict[str, str]]:
368
+ sp = self.secrets_path()
369
+ if not sp.exists():
370
+ return {}
371
+ try:
372
+ data = json.loads(self._cipher().decrypt(sp.read_bytes(), _SECRETS_AAD))
373
+ except (InvalidTag, ValueError) as e:
374
+ raise crypto.KeyUnavailable("the secrets file can't be decrypted") from e
375
+ return data if isinstance(data, dict) else {}
376
+
377
+ def _write_secrets(self, data: dict[str, dict[str, str]]) -> None:
378
+ blob = self._cipher().encrypt(json.dumps(data).encode(), _SECRETS_AAD)
379
+ _atomic_write(self.secrets_path(), blob)
380
+
381
+ def list_secrets(self) -> list[tuple[str, str]]:
382
+ return [(s, u) for s, users in self._read_secrets().items() for u in users]
383
+
384
+ def get_secret(self, service: str, user: str) -> str | None:
385
+ return self._read_secrets().get(service, {}).get(user)
386
+
387
+ def set_secret(self, service: str, user: str, value: str) -> None:
388
+ data = self._read_secrets()
389
+ data.setdefault(service, {})[user] = value
390
+ self._write_secrets(data)
391
+
392
+ def delete_secret(self, service: str, user: str) -> None:
393
+ sp = self.secrets_path()
394
+ if not sp.exists():
395
+ return
396
+ data = self._read_secrets()
397
+ if data.get(service, {}).pop(user, None) is not None:
398
+ if not data[service]:
399
+ del data[service]
400
+ self._write_secrets(data)
401
+
402
+
403
+ class SystemdCredsBackend(PassphraseBackend):
404
+ """Passphrase from systemd's $CREDENTIALS_DIRECTORY (LoadCredential=/SetCredential=)."""
405
+
406
+ name = "systemd-creds"
407
+
408
+ @staticmethod
409
+ def cred_path() -> Path | None:
410
+ d = os.environ.get("CREDENTIALS_DIRECTORY")
411
+ return Path(d) / CRED_NAME if d else None
412
+
413
+ def _source_available(self) -> bool:
414
+ p = self.cred_path()
415
+ return p is not None and p.is_file()
416
+
417
+ def _passphrase(self, confirm: bool) -> str:
418
+ if self._getter is not None:
419
+ return self._getter(confirm)
420
+ p = self.cred_path()
421
+ if p is None or not p.is_file():
422
+ raise crypto.KeyUnavailable(
423
+ f"systemd credential '{CRED_NAME}' is missing; add "
424
+ f"LoadCredential={CRED_NAME}:<path> to the service"
425
+ )
426
+ # systemd may grant the service's group read access; world access is still refused.
427
+ pw = _read_secret_file(p, allow_group=True)
428
+ if confirm and len(pw) < MIN_PASSPHRASE_LEN:
429
+ raise crypto.KeyUnavailable(
430
+ f"the passphrase must be at least {MIN_PASSPHRASE_LEN} characters"
431
+ )
432
+ return pw
433
+
434
+
435
+ # ------------------------------------------------------------------------------ selection
436
+
437
+ def make(name: str) -> SecretBackend:
438
+ if name == "keyring":
439
+ return KeyringBackend()
440
+ if name == "passphrase":
441
+ return PassphraseBackend()
442
+ if name == "systemd-creds":
443
+ return SystemdCredsBackend()
444
+ raise ValueError(f"unknown secrets backend {name!r}; choose one of {', '.join(BACKENDS)}")
445
+
446
+
447
+ _FALLBACK_NOTICE = (
448
+ "This computer has no system keychain Free My ID can use, so your data will be "
449
+ "protected with a passphrase instead. Keep it somewhere safe: without it the saved "
450
+ "data can't be opened."
451
+ )
452
+
453
+
454
+ _noticed = False
455
+
456
+
457
+ def configured_name() -> str | None:
458
+ from . import config
459
+ return config.load().secrets_backend
460
+
461
+
462
+ def select() -> SecretBackend:
463
+ """The backend to use now. Explicit config wins; otherwise auto-detect (see module doc)."""
464
+ if (name := configured_name()) is not None:
465
+ return make(name)
466
+ creds = SystemdCredsBackend()
467
+ if PassphraseBackend.wrap_path().exists():
468
+ return creds if creds._source_available() else PassphraseBackend()
469
+ kr = KeyringBackend()
470
+ if kr.usable():
471
+ return kr
472
+ if paths.db_path().exists():
473
+ # Data exists but has no passphrase wrap: it was locked with a keychain that is
474
+ # gone now. Creating a fresh key would orphan it, so fail closed.
475
+ raise crypto.KeyUnavailable(
476
+ "Your saved data is locked with the system keychain, which isn't available "
477
+ "right now. Unlock or start the keychain, then run 'freemyid secrets migrate "
478
+ "--to passphrase' if you want to stop depending on it"
479
+ )
480
+ if creds._source_available():
481
+ return creds
482
+ global _noticed
483
+ if not _noticed:
484
+ _noticed = True
485
+ log.warning(_FALLBACK_NOTICE)
486
+ if _interactive():
487
+ print(_FALLBACK_NOTICE, file=sys.stderr)
488
+ return PassphraseBackend()
489
+
490
+
491
+ # ------------------------------------------------------------------------------ migration
492
+
493
+ @dataclass
494
+ class MigrateResult:
495
+ from_backend: str
496
+ to_backend: str
497
+ secrets_moved: int
498
+ changed: bool
499
+
500
+
501
+ def _known_secret_ids(src: SecretBackend, key: bytes) -> list[tuple[str, str]]:
502
+ """Every (service, user) that may hold a secret. The keychain can't be listed, so ask
503
+ the store which mailboxes exist."""
504
+ ids: set[tuple[str, str]] = set()
505
+ if isinstance(src, PassphraseBackend):
506
+ ids.update(src.list_secrets())
507
+ if paths.db_path().exists():
508
+ from .secrets import _user
509
+ from .storage import Store
510
+ store = Store(cipher=crypto.Cipher(key))
511
+ try:
512
+ for p in store.list_profiles():
513
+ if p.mailbox:
514
+ ids.add((p.mailbox.keyring_service, _user(p.id, p.mailbox)))
515
+ finally:
516
+ store.close()
517
+ return sorted(ids)
518
+
519
+
520
+ def migrate(to: str) -> MigrateResult:
521
+ """Re-wrap the data key (and move mailbox passwords) into backend `to`. No data loss:
522
+ the target is written and verified, config is switched, and only then is the old copy
523
+ removed. Encrypted data in the store is untouched (same data key)."""
524
+ target = make(to)
525
+ from . import config
526
+ cfg = config.load()
527
+ source = select()
528
+ if source.name == target.name or (
529
+ isinstance(source, PassphraseBackend) and isinstance(target, PassphraseBackend)
530
+ ):
531
+ # Passphrase and systemd-creds share one wrap file; only the setting changes.
532
+ changed = cfg.secrets_backend != target.name
533
+ if changed:
534
+ cfg.secrets_backend = target.name # type: ignore[assignment]
535
+ config.save(cfg)
536
+ return MigrateResult(source.name, target.name, 0, changed)
537
+
538
+ key = source.load_data_key(create=False)
539
+ ids = _known_secret_ids(source, key)
540
+ values = {i: v for i in ids if (v := source.get_secret(*i)) is not None}
541
+
542
+ target.store_data_key(key)
543
+ if not hmac.compare_digest(target.load_data_key(create=False), key):
544
+ raise crypto.KeyUnavailable("migration check failed: the new copy of the key differs")
545
+ # Target secrets are encrypted with the data key; route Cipher() to the target now.
546
+ cfg.secrets_backend = target.name # type: ignore[assignment]
547
+ config.save(cfg)
548
+ try:
549
+ for (svc, user), val in values.items():
550
+ target.set_secret(svc, user, val)
551
+ if target.get_secret(svc, user) != val:
552
+ raise crypto.KeyUnavailable("migration check failed for a saved password")
553
+ except Exception:
554
+ cfg.secrets_backend = source.name # type: ignore[assignment]
555
+ config.save(cfg)
556
+ raise
557
+ for svc, user in values:
558
+ source.delete_secret(svc, user)
559
+ source.delete_data_key()
560
+ log.info("secrets migrated from %s to %s (%d saved passwords)",
561
+ source.name, target.name, len(values))
562
+ return MigrateResult(source.name, target.name, len(values), True)
freemyid/secrets.py ADDED
@@ -0,0 +1,25 @@
1
+ """Mailbox passwords, via the selected secrets backend. OWNER: orchestrator.
2
+
3
+ Keyring backend (default): the OS keychain, as before. Passphrase / systemd-creds backends:
4
+ an encrypted file in FREEMYID_HOME (see secret_backends). Never stored in clear text.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from . import secret_backends
9
+ from .models import MailboxConfig
10
+
11
+
12
+ def _user(profile_id: str, mb: MailboxConfig) -> str:
13
+ return f"{profile_id}:{mb.address}"
14
+
15
+
16
+ def set_mailbox_password(profile_id: str, mb: MailboxConfig, password: str) -> None:
17
+ secret_backends.select().set_secret(mb.keyring_service, _user(profile_id, mb), password)
18
+
19
+
20
+ def get_mailbox_password(profile_id: str, mb: MailboxConfig) -> str | None:
21
+ return secret_backends.select().get_secret(mb.keyring_service, _user(profile_id, mb))
22
+
23
+
24
+ def delete_mailbox_password(profile_id: str, mb: MailboxConfig) -> None:
25
+ secret_backends.select().delete_secret(mb.keyring_service, _user(profile_id, mb))