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.
- freemyid/__init__.py +2 -0
- freemyid/_data/brokers/LICENSE-DATA.md +28 -0
- freemyid/_data/brokers/registry.sample.yaml +674 -0
- freemyid/_data/brokers/state_laws.yaml +448 -0
- freemyid/_data/schema/registry.schema.json +668 -0
- freemyid/_data/schema/setup-config.schema.json +538 -0
- freemyid/_data/schema/state_laws.schema.json +222 -0
- freemyid/cli.py +725 -0
- freemyid/cli_json.py +198 -0
- freemyid/config.py +43 -0
- freemyid/consent.py +82 -0
- freemyid/crypto.py +197 -0
- freemyid/doctor.py +374 -0
- freemyid/engine/__init__.py +0 -0
- freemyid/engine/_dev/__init__.py +101 -0
- freemyid/engine/_dev/devrun.py +101 -0
- freemyid/engine/_dev/inbox/devauto.eml +7 -0
- freemyid/engine/_dev/registry.yaml +106 -0
- freemyid/engine/_dev/site/devauto.example/confirm.html +1 -0
- freemyid/engine/_dev/site/devauto.example/name/jane-testperson/il.html +17 -0
- freemyid/engine/_dev/site/devauto.example/name/jane-testperson/nc.html +4 -0
- freemyid/engine/_dev/site/devauto.example/name/jane-testperson/oh.html +4 -0
- freemyid/engine/_dev/site/devauto.example/optout/done.html +1 -0
- freemyid/engine/_dev/site/devauto.example/optout.html +6 -0
- freemyid/engine/_dev/site/devblocked.example/name/jane-testperson/il.html +3 -0
- freemyid/engine/_dev/site/devblocked.example/optout.html +3 -0
- freemyid/engine/_dev/site/devfill.example/ajax.html +20 -0
- freemyid/engine/_dev/site/devfill.example/aria.html +16 -0
- freemyid/engine/_dev/site/devfill.example/consent.html +20 -0
- freemyid/engine/_dev/site/devfill.example/done.html +3 -0
- freemyid/engine/_dev/site/devfill.example/empty.html +1 -0
- freemyid/engine/_dev/site/devfill.example/fullname.html +12 -0
- freemyid/engine/_dev/site/devfill.example/labels.html +32 -0
- freemyid/engine/_dev/site/devfill.example/late.html +22 -0
- freemyid/engine/_dev/site/devfind.example/hostile.html +12 -0
- freemyid/engine/_dev/site/devfind.example/p/jane-testperson-shelbyville-il.html +6 -0
- freemyid/engine/_dev/site/devfind.example/p/jane-testperson-springfield-il.html +6 -0
- freemyid/engine/_dev/site/devfind.example/results.html +11 -0
- freemyid/engine/_dev/site/devfind.example/search.html +16 -0
- freemyid/engine/_dev/site/devweb.example/remove.html +1 -0
- freemyid/engine/browser.py +430 -0
- freemyid/engine/fill_for_me.py +630 -0
- freemyid/engine/filler.py +523 -0
- freemyid/engine/handoff.py +237 -0
- freemyid/engine/letters.py +125 -0
- freemyid/engine/listing_finder.py +538 -0
- freemyid/engine/mail.py +406 -0
- freemyid/engine/mail_tls.py +198 -0
- freemyid/engine/matcher.py +208 -0
- freemyid/engine/outbox.py +421 -0
- freemyid/engine/plugins.py +68 -0
- freemyid/engine/runner.py +791 -0
- freemyid/engine/tiers.py +107 -0
- freemyid/interfaces.py +164 -0
- freemyid/logging_setup.py +152 -0
- freemyid/models.py +700 -0
- freemyid/netpolicy.py +145 -0
- freemyid/notify.py +58 -0
- freemyid/paths.py +83 -0
- freemyid/registry.py +275 -0
- freemyid/scheduler/__init__.py +203 -0
- freemyid/scheduler/render.py +91 -0
- freemyid/secret_backends.py +562 -0
- freemyid/secrets.py +25 -0
- freemyid/setup_config.py +311 -0
- freemyid/state.py +151 -0
- freemyid/storage/__init__.py +3 -0
- freemyid/storage/db.py +394 -0
- freemyid/submitters/__init__.py +0 -0
- freemyid/submitters/assisted.py +171 -0
- freemyid/submitters/base.py +119 -0
- freemyid/submitters/email.py +40 -0
- freemyid/submitters/web_form.py +276 -0
- freemyid/templates/_identity.txt.j2 +19 -0
- freemyid/templates/_signature.txt.j2 +7 -0
- freemyid/templates/ccpa.txt.j2 +16 -0
- freemyid/templates/follow_up.txt.j2 +15 -0
- freemyid/templates/generic_removal.txt.j2 +12 -0
- freemyid/templates/legal_demand.txt.j2 +20 -0
- freemyid/templates/phone_script.txt.j2 +25 -0
- freemyid/templates/postal_letter.txt.j2 +20 -0
- freemyid/templates/state_privacy.txt.j2 +17 -0
- freemyid/wizard/__init__.py +0 -0
- freemyid/wizard/dashboard.py +401 -0
- freemyid/wizard/desktop.py +188 -0
- freemyid/wizard/draft.py +627 -0
- freemyid/wizard/firstrun.py +42 -0
- freemyid/wizard/flows.py +283 -0
- freemyid/wizard/report.py +86 -0
- freemyid/wizard/server.py +834 -0
- freemyid/wizard/static/__init__.py +0 -0
- freemyid/wizard/static/app.js +98 -0
- freemyid/wizard/static/freemyid.png +0 -0
- freemyid/wizard/static/style.css +85 -0
- freemyid/wizard/ui.py +343 -0
- freemyid-0.2.0.dev1.dist-info/METADATA +396 -0
- freemyid-0.2.0.dev1.dist-info/RECORD +100 -0
- freemyid-0.2.0.dev1.dist-info/WHEEL +4 -0
- freemyid-0.2.0.dev1.dist-info/entry_points.txt +2 -0
- 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))
|