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
freemyid/cli_json.py ADDED
@@ -0,0 +1,198 @@
1
+ """Stable machine-readable output for the CLI (`--json`) and the documented exit codes.
2
+
3
+ OWNER: automation-engineer (CLI v0.2). Reference: docs/CLI.md.
4
+
5
+ Rules for every shape here:
6
+ * each document is one JSON object with ``"schema": "<name>/1"``; fields are only ever
7
+ added, never renamed or removed, within a schema version;
8
+ * no PII beyond what the human output already shows (profile labels, broker names, to-do
9
+ titles), never secrets, never copy-fields, letters, addresses or mailbox details;
10
+ * timestamps are ISO 8601 UTC strings or null.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import sys
16
+ from datetime import date, datetime
17
+ from typing import TYPE_CHECKING, Any
18
+
19
+ if TYPE_CHECKING: # pragma: no cover
20
+ from .engine.runner import RunReport
21
+ from .models import BrokerStatus, Profile, Registry, Request, Todo
22
+
23
+ # Exit codes (docs/CLI.md). Never reuse a number for something else.
24
+ EXIT_OK = 0
25
+ EXIT_ERROR = 1
26
+ EXIT_USAGE = 2
27
+ EXIT_NEEDS_HUMAN = 3
28
+ EXIT_NOT_CONFIGURED = 4
29
+
30
+
31
+ def _ts(d: datetime | date | None) -> str | None:
32
+ return d.isoformat() if d is not None else None
33
+
34
+
35
+ def emit(doc: dict[str, Any]) -> None:
36
+ """Write one JSON document to stdout (plain print: rich would wrap long lines)."""
37
+ sys.stdout.write(json.dumps(doc, sort_keys=False, ensure_ascii=False) + "\n")
38
+ sys.stdout.flush()
39
+
40
+
41
+ def skipped_entry(p: Profile, reason: str) -> dict[str, Any]:
42
+ return {"profile_id": p.id, "label": p.label, "reason": reason}
43
+
44
+
45
+ def status_doc(rows: list[tuple[Profile, list[BrokerStatus], int]], names: dict[str, str],
46
+ skipped: list[dict[str, Any]]) -> dict[str, Any]:
47
+ return {
48
+ "schema": "status/1",
49
+ "profiles": [{
50
+ "profile_id": p.id,
51
+ "label": p.label,
52
+ "open_todos": todos,
53
+ "brokers": [{
54
+ "broker_id": st.broker_id,
55
+ "broker_name": names.get(st.broker_id, st.broker_id),
56
+ "state": st.state.value,
57
+ "tier": st.tier.value if st.tier else None,
58
+ "updated_at": _ts(st.updated_at),
59
+ "last_scan_at": _ts(st.last_scan_at),
60
+ "last_submitted_at": _ts(st.last_submitted_at),
61
+ "next_check_at": _ts(st.next_check_at),
62
+ "attempts": st.attempts,
63
+ "escalation_level": st.escalation_level,
64
+ } for st in sorted(sts, key=lambda s: s.broker_id)],
65
+ } for p, sts, todos in rows],
66
+ "skipped": skipped,
67
+ }
68
+
69
+
70
+ def profiles_doc(rows: list[tuple[Profile, bool]]) -> dict[str, Any]:
71
+ return {
72
+ "schema": "profiles/1",
73
+ "profiles": [{
74
+ "id": p.id,
75
+ "label": p.label,
76
+ "state_of_residence": p.state_of_residence,
77
+ "consent_basis": p.consent.basis.value,
78
+ "consent_ok": ok,
79
+ "has_mailbox": p.mailbox is not None,
80
+ "email_mode": p.email_mode.value,
81
+ "created_at": _ts(p.created_at),
82
+ } for p, ok in rows],
83
+ }
84
+
85
+
86
+ def todos_doc(rows: list[tuple[Profile, Todo]], skipped: list[dict[str, Any]]) -> dict[str, Any]:
87
+ return {
88
+ "schema": "todos/1",
89
+ "todos": [{
90
+ "id": t.id,
91
+ "profile_id": p.id,
92
+ "label": p.label,
93
+ "broker_id": t.broker_id,
94
+ "kind": t.kind.value,
95
+ "title": t.title,
96
+ "request_id": t.request_id,
97
+ "created_at": _ts(t.created_at),
98
+ } for p, t in rows],
99
+ "skipped": skipped,
100
+ }
101
+
102
+
103
+ def outbox_doc(reqs: list[Request]) -> dict[str, Any]:
104
+ return {
105
+ "schema": "outbox/1",
106
+ "requests": [{
107
+ "id": r.id,
108
+ "profile_id": r.profile_id,
109
+ "broker_id": r.broker_id,
110
+ "kind": r.kind.value,
111
+ "method": r.method.value,
112
+ "status": r.status.value,
113
+ "attempt": r.attempt,
114
+ "created_at": _ts(r.created_at),
115
+ } for r in reqs],
116
+ }
117
+
118
+
119
+ def registry_doc(reg: Registry, source: str, warnings: list[str]) -> dict[str, Any]:
120
+ return {
121
+ "schema": "registry/1",
122
+ "version": reg.version,
123
+ "updated": _ts(reg.updated),
124
+ "source": source,
125
+ "warnings": list(warnings),
126
+ "brokers": [{
127
+ "id": b.id,
128
+ "name": b.name,
129
+ "url": str(b.url),
130
+ "category": b.category.value,
131
+ "method": b.method.value,
132
+ "verification_status": b.verification_status.value,
133
+ "has_captcha": b.has_captcha,
134
+ "covered_by": b.covered_by,
135
+ "priority": b.priority,
136
+ "enabled": b.enabled,
137
+ } for b in sorted(reg.brokers, key=lambda b: (-b.priority, b.id))],
138
+ }
139
+
140
+
141
+ def registry_source_doc(reg: Registry, source: str, warnings: list[str]) -> dict[str, Any]:
142
+ return {"schema": "registry-source/1", "source": source, "version": reg.version,
143
+ "updated": _ts(reg.updated), "brokers": len(reg.brokers),
144
+ "warnings": list(warnings)}
145
+
146
+
147
+ def schedule_doc(platform: str | None, detail: str) -> dict[str, Any]:
148
+ low = detail.lower()
149
+ installed = bool(detail) and "not installed" not in low and "not supported" not in low
150
+ return {"schema": "schedule/1", "platform": platform, "installed": installed,
151
+ "detail": detail}
152
+
153
+
154
+ def reports_doc(rows: list[tuple[Profile, str, str]], skipped: list[dict[str, Any]]
155
+ ) -> dict[str, Any]:
156
+ return {"schema": "reports/1",
157
+ "reports": [{"profile_id": p.id, "label": p.label, "format": fmt, "path": path}
158
+ for p, fmt, path in rows],
159
+ "skipped": skipped}
160
+
161
+
162
+ def run_doc(rep: RunReport, *, scan_only: bool, headless: bool, exit_code: int,
163
+ skipped: list[dict[str, Any]]) -> dict[str, Any]:
164
+ return {
165
+ "schema": "run/1",
166
+ "dry_run": rep.dry_run,
167
+ "scan_only": scan_only,
168
+ "headless": headless,
169
+ "exit_code": exit_code,
170
+ "profiles": [{
171
+ "profile_id": pid,
172
+ "label": rep.labels.get(pid, pid[:8]),
173
+ "states": {k.value: v for k, v in sorted(counts.items(), key=lambda kv: kv[0].value)},
174
+ "tiers": {t.value: n for t, n in sorted(rep.tiers.get(pid, {}).items())},
175
+ } for pid, counts in rep.per_profile.items()],
176
+ "needs_human": rep.needs_human,
177
+ "errors": rep.errors,
178
+ "skipped_not_due": rep.skipped_not_due,
179
+ "skipped_profiles": rep.skipped_profiles,
180
+ "submitted": rep.submitted,
181
+ "queued_emails": rep.queued_emails,
182
+ "drafted_emails": rep.drafted_emails,
183
+ "open_todos": rep.open_todos,
184
+ "skipped": skipped,
185
+ "summary": rep.summary_lines(),
186
+ }
187
+
188
+
189
+ def secrets_doc(backend: str, configured: str | None, usable: bool,
190
+ keychain_available: bool) -> dict[str, Any]:
191
+ """Backend names only; never a key, passphrase, path contents or password."""
192
+ return {"schema": "secrets/1", "backend": backend, "configured": configured,
193
+ "usable": usable, "keychain_available": keychain_available}
194
+
195
+
196
+ def failure_doc(code: int, reason: str) -> dict[str, Any]:
197
+ """What `run --headless --json` prints when it fails closed before running."""
198
+ return {"schema": "error/1", "exit_code": code, "reason": reason}
freemyid/config.py ADDED
@@ -0,0 +1,43 @@
1
+ """App config (non-PII) in FREEMYID_HOME/config.yaml. OWNER: orchestrator."""
2
+ from __future__ import annotations
3
+
4
+ import sys
5
+ from typing import Literal
6
+
7
+ import yaml
8
+ from pydantic import BaseModel, ConfigDict
9
+
10
+ from . import paths
11
+
12
+
13
+ class Config(BaseModel):
14
+ model_config = ConfigDict(extra="ignore")
15
+
16
+ registry_url: str | None = None # raw URL to registry.yaml in the user's GitHub repo
17
+ registry_sha256_url: str | None = None # optional: URL of a .sha256 file to pin content
18
+ extra_allowed_hosts: list[str] = [] # e.g. playwright CDN if user approved download
19
+ browser_channel: str | None = "msedge" if sys.platform == "win32" else "chrome"
20
+ browser_automation: bool = True # Automatic tier on/off (engine.tiers)
21
+ headless: bool = True
22
+ min_delay_s: float = 3.0
23
+ max_delay_s: float = 12.0
24
+ max_retries: int = 3
25
+ user_agent_suffix: str = "FreeMyID/0.1 (personal privacy opt-out tool)"
26
+ wizard_port: int = 8765
27
+ real_submissions_enabled: bool = False # flipped only after explicit user approval
28
+ # Where the data key and mailbox passwords live (secret_backends). None = auto: OS
29
+ # keychain if usable, else passphrase. Change with `freemyid secrets migrate`.
30
+ secrets_backend: Literal["keyring", "passphrase", "systemd-creds"] | None = None
31
+
32
+
33
+ def load() -> Config:
34
+ p = paths.config_path()
35
+ if p.exists():
36
+ return Config.model_validate(yaml.safe_load(p.read_text()) or {})
37
+ return Config()
38
+
39
+
40
+ def save(cfg: Config) -> None:
41
+ p = paths.config_path()
42
+ p.write_text(yaml.safe_dump(cfg.model_dump(), sort_keys=False))
43
+ p.chmod(0o600)
freemyid/consent.py ADDED
@@ -0,0 +1,82 @@
1
+ """The one consent gate. The CLI, the runner and the wizard all import it.
2
+
3
+ OWNER: orchestrator (written by peer session e8 for SEC-06).
4
+
5
+ Rules (fail closed):
6
+ * someone has to have typed their name to attest;
7
+ * the consent text version must be one this build knows about;
8
+ * a minor (``is_minor`` flag OR an age computed from dob/age under 18) needs
9
+ GUARDIAN_OF_MINOR;
10
+ * an adult needs SELF or AUTHORIZED_AGENT (GUARDIAN_OF_MINOR is refused);
11
+ * AUTHORIZED_AGENT needs ``authorization_doc`` to name stored consent evidence
12
+ for this same profile, and its encrypted file has to exist on disk.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ from datetime import date
17
+ from typing import TYPE_CHECKING
18
+
19
+ from . import paths
20
+ from .models import ConsentBasis, Profile
21
+
22
+ if TYPE_CHECKING: # pragma: no cover
23
+ from .storage import Store
24
+
25
+ #: Version id of the consent text the wizard currently shows.
26
+ CURRENT_TEXT_VERSION = "2026-11-c"
27
+ #: Every consent text version a stored profile may have accepted. Add (never remove)
28
+ #: when the wording changes in a way that doesn't need everyone to consent again.
29
+ ACCEPTED_TEXT_VERSIONS: frozenset[str] = frozenset({"2026-11-a", "2026-11-b", CURRENT_TEXT_VERSION})
30
+ #: broker_id the wizard uses when it saves the signed authorization as evidence.
31
+ CONSENT_EVIDENCE_BROKER = "_consent"
32
+
33
+ ADULT_AGE = 18
34
+
35
+
36
+ def is_minor(p: Profile, today: date | None = None) -> bool:
37
+ """True if the profile is flagged as a minor OR its estimated age is under 18."""
38
+ if p.is_minor:
39
+ return True
40
+ age = p.estimated_age(today)
41
+ return age is not None and age < ADULT_AGE
42
+
43
+
44
+ def authorization_on_file(p: Profile, store: Store) -> bool:
45
+ """The signed authorization named in the consent is stored for this profile and on disk."""
46
+ doc = p.consent.authorization_doc
47
+ if not doc:
48
+ return False
49
+ for ev in store.list_evidence(p.id, CONSENT_EVIDENCE_BROKER):
50
+ if ev.id == doc and ev.profile_id == p.id:
51
+ return (paths.evidence_dir() / ev.relpath).is_file()
52
+ return False
53
+
54
+
55
+ def consent_ok(p: Profile, store: Store | None = None, *,
56
+ today: date | None = None) -> tuple[bool, str]:
57
+ """Hard consent gate. Returns ``(ok, reason)``; reason is "" when ok.
58
+
59
+ Pass ``store`` so the authorized-agent document can be checked. Without a store an
60
+ AUTHORIZED_AGENT profile is refused (fail closed).
61
+ """
62
+ c = p.consent
63
+ if not c.attested_by.strip():
64
+ return False, "consent has no attesting name"
65
+ if c.text_version not in ACCEPTED_TEXT_VERSIONS:
66
+ return False, "consent was given to an older or unknown consent text; please re-confirm"
67
+ if is_minor(p, today):
68
+ if c.basis != ConsentBasis.GUARDIAN_OF_MINOR:
69
+ return False, "minor profile requires parent/guardian consent"
70
+ return True, ""
71
+ if c.basis == ConsentBasis.GUARDIAN_OF_MINOR:
72
+ return False, ("guardian consent only covers people under 18; this adult must "
73
+ "consent themselves or sign an agent authorization")
74
+ if c.basis == ConsentBasis.AUTHORIZED_AGENT:
75
+ if not c.authorization_doc:
76
+ return False, "authorized agent needs the signed authorization attached"
77
+ if store is None or not authorization_on_file(p, store):
78
+ return False, "the signed authorization could not be found; please attach it again"
79
+ return True, ""
80
+ if c.basis == ConsentBasis.SELF:
81
+ return True, ""
82
+ return False, f"unknown consent basis {c.basis!r}"
freemyid/crypto.py ADDED
@@ -0,0 +1,197 @@
1
+ """At-rest encryption. Key lives in the OS keychain or a passphrase wrap. OWNER: orchestrator.
2
+
3
+ Where the key lives is decided by `secret_backends` (keyring | passphrase | systemd-creds).
4
+
5
+ Field/blob-level AES-256-GCM. Ciphertext format: b"V1" + 12-byte nonce + ct||tag.
6
+ SQLCipher was considered; blob encryption avoids cross-platform native packaging issues.
7
+ Only ids, enums and timestamps are stored in clear; all PII is inside encrypted blobs.
8
+
9
+ Evidence files are bound (AAD) to their path relative to FREEMYID_HOME/evidence, so a file
10
+ cannot be swapped between profiles/brokers. Store a `key_check()` value in the DB `meta`
11
+ table on first use and `verify_key_check()` it on open to detect a wrong/lost key early.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import base64
16
+ import hashlib
17
+ import logging
18
+ import os
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ import keyring
23
+ import keyring.errors
24
+ from cryptography.exceptions import InvalidTag
25
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
26
+
27
+ from . import paths
28
+
29
+ SERVICE = "freemyid"
30
+ KEY_NAME = "db-key-v1"
31
+ _MAGIC = b"V1"
32
+ _KEY_CHECK_PLAINTEXT = b"freemyid-key-check-v1"
33
+ _KEY_CHECK_AAD = b"meta:key_check"
34
+ _FILE_AAD_PREFIX = b"evidence:"
35
+
36
+ log = logging.getLogger(__name__)
37
+
38
+ # Backends that would silently lose the key or store it in clear text.
39
+ _REJECTED_BACKENDS = {
40
+ "keyring.backends.fail.Keyring",
41
+ "keyring.backends.null.Keyring",
42
+ "keyrings.alt.file.PlaintextKeyring",
43
+ }
44
+
45
+
46
+ class KeyUnavailable(RuntimeError):
47
+ """OS keychain locked/unavailable (e.g. headless session). Surface as NEEDS_HUMAN."""
48
+
49
+
50
+ def _test_key() -> bytes | None:
51
+ """FREEMYID_TEST_KEY is honored only under pytest or with FREEMYID_ALLOW_TEST_KEY=1.
52
+
53
+ The test key is a SHA-256 of an env string (no KDF): never acceptable for real data.
54
+ A leftover variable outside tests is refused loudly rather than silently ignored, so
55
+ data is never split between two keys.
56
+ """
57
+ test_key = os.environ.get("FREEMYID_TEST_KEY")
58
+ if not test_key:
59
+ return None
60
+ if "pytest" in sys.modules or os.environ.get("FREEMYID_ALLOW_TEST_KEY") == "1":
61
+ log.warning("Using FREEMYID_TEST_KEY: weak test-only key, never use with real data")
62
+ return hashlib.sha256(test_key.encode()).digest()
63
+ raise KeyUnavailable(
64
+ "FREEMYID_TEST_KEY is set outside the test suite; unset it to use the OS keychain"
65
+ )
66
+
67
+
68
+ def _backend_names(kr: object) -> list[str]:
69
+ backends = getattr(kr, "backends", None) # ChainerBackend
70
+ objs = list(backends) if backends else [kr]
71
+ return [f"{type(b).__module__}.{type(b).__qualname__}" for b in objs]
72
+
73
+
74
+ def _check_backend() -> None:
75
+ names = _backend_names(keyring.get_keyring())
76
+ bad = [n for n in names if n in _REJECTED_BACKENDS]
77
+ if bad and len(bad) == len(names):
78
+ raise KeyUnavailable(f"no secure OS keychain available (backend: {', '.join(bad)})")
79
+
80
+
81
+ def _decode_key(stored: str) -> bytes:
82
+ try:
83
+ key = base64.b64decode(stored, validate=True)
84
+ except ValueError as e:
85
+ raise KeyUnavailable("stored key is corrupt") from e
86
+ if len(key) != 32:
87
+ raise KeyUnavailable("stored key has the wrong length")
88
+ return key
89
+
90
+
91
+ def _get_or_create_key() -> bytes:
92
+ """Test key (pytest only), else the selected secrets backend (secret_backends.select)."""
93
+ if (tk := _test_key()) is not None:
94
+ return tk
95
+ from . import secret_backends
96
+ return secret_backends.select().load_data_key()
97
+
98
+
99
+ def _keyring_get_or_create_key() -> bytes:
100
+ """The OS keychain path (default backend; unchanged since 0.1)."""
101
+ try:
102
+ _check_backend()
103
+ stored = keyring.get_password(SERVICE, KEY_NAME)
104
+ if stored is None:
105
+ key = AESGCM.generate_key(bit_length=256)
106
+ keyring.set_password(SERVICE, KEY_NAME, base64.b64encode(key).decode())
107
+ # Re-read: if another process won a first-run race, use its key, not ours.
108
+ stored = keyring.get_password(SERVICE, KEY_NAME)
109
+ if stored is None:
110
+ raise KeyUnavailable("keychain did not persist the key")
111
+ return _decode_key(stored)
112
+ except keyring.errors.KeyringError as e:
113
+ raise KeyUnavailable(str(e)) from e
114
+
115
+
116
+ def delete_key() -> None:
117
+ """Remove the data key from every backend (used by `freemyid wipe`): the OS keychain
118
+ entry and the passphrase-wrapped key file. Missing key or no keychain at all is fine."""
119
+ from . import secret_backends
120
+ secret_backends.KeyringBackend().delete_data_key()
121
+ secret_backends.PassphraseBackend().delete_data_key()
122
+
123
+
124
+ class Cipher:
125
+ def __init__(self, key: bytes | None = None) -> None:
126
+ if key is None:
127
+ key = _get_or_create_key()
128
+ elif len(key) != 32:
129
+ raise ValueError("Cipher key must be 32 bytes (AES-256)")
130
+ self._aes = AESGCM(key)
131
+
132
+ def encrypt(self, data: bytes, aad: bytes = b"") -> bytes:
133
+ nonce = os.urandom(12)
134
+ return _MAGIC + nonce + self._aes.encrypt(nonce, data, aad or None)
135
+
136
+ def decrypt(self, blob: bytes, aad: bytes = b"") -> bytes:
137
+ if blob[:2] != _MAGIC:
138
+ raise ValueError("unknown ciphertext format")
139
+ return self._aes.decrypt(blob[2:14], blob[14:], aad or None)
140
+
141
+ # --- key check (store in DB meta to detect a wrong key before touching data) ---------
142
+
143
+ def key_check(self) -> str:
144
+ """Encrypted constant, base64 text, for storing in `meta`."""
145
+ return base64.b64encode(self.encrypt(_KEY_CHECK_PLAINTEXT, _KEY_CHECK_AAD)).decode()
146
+
147
+ def verify_key_check(self, value: str) -> bool:
148
+ try:
149
+ pt = self.decrypt(base64.b64decode(value), _KEY_CHECK_AAD)
150
+ except (InvalidTag, ValueError):
151
+ return False
152
+ return pt == _KEY_CHECK_PLAINTEXT
153
+
154
+ # --- files ----------------------------------------------------------------------------
155
+
156
+ @staticmethod
157
+ def _file_aad(path: Path) -> bytes:
158
+ """AAD = path relative to FREEMYID_HOME/evidence (POSIX form), so it survives moving
159
+ FREEMYID_HOME. Files outside the evidence dir are bound to their absolute path."""
160
+ p = Path(os.path.abspath(path))
161
+ base = Path(os.path.abspath(paths.home() / "evidence"))
162
+ try:
163
+ rel = p.relative_to(base).as_posix()
164
+ except ValueError:
165
+ rel = p.as_posix()
166
+ return _FILE_AAD_PREFIX + rel.encode()
167
+
168
+ def encrypt_file(self, src_bytes: bytes, dest: Path) -> str:
169
+ """Write encrypted file; return sha256 of the plaintext."""
170
+ dest.parent.mkdir(parents=True, exist_ok=True)
171
+ dest.write_bytes(self.encrypt(src_bytes, aad=self._file_aad(dest)))
172
+ dest.chmod(0o600)
173
+ return hashlib.sha256(src_bytes).hexdigest()
174
+
175
+ def decrypt_file(self, path: Path) -> bytes:
176
+ return self.decrypt(path.read_bytes(), aad=self._file_aad(path))
177
+
178
+
179
+ # --- passphrase-based export (family installer pre-filled profiles) --------------------------
180
+
181
+ def export_with_passphrase(data: bytes, passphrase: str) -> bytes:
182
+ from cryptography.hazmat.primitives.kdf.scrypt import Scrypt
183
+
184
+ salt = os.urandom(16)
185
+ key = Scrypt(salt=salt, length=32, n=2**15, r=8, p=1).derive(passphrase.encode())
186
+ nonce = os.urandom(12)
187
+ return b"VP1" + salt + nonce + AESGCM(key).encrypt(nonce, data, b"freemyid-profile")
188
+
189
+
190
+ def import_with_passphrase(blob: bytes, passphrase: str) -> bytes:
191
+ from cryptography.hazmat.primitives.kdf.scrypt import Scrypt
192
+
193
+ if blob[:3] != b"VP1":
194
+ raise ValueError("not a Free My ID profile file")
195
+ salt, nonce, ct = blob[3:19], blob[19:31], blob[31:]
196
+ key = Scrypt(salt=salt, length=32, n=2**15, r=8, p=1).derive(passphrase.encode())
197
+ return AESGCM(key).decrypt(nonce, ct, b"freemyid-profile")