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