keyfleet 0.1.0__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.
keyfleet/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """keyfleet — a local-first ledger for hardware security keys.
2
+
3
+ Maps keys ↔ accounts ↔ credential types and reports coverage gaps, lost-key
4
+ impact, capacity, and advisories. Stores no secrets; makes no network calls.
5
+ """
keyfleet/bundled.py ADDED
@@ -0,0 +1,243 @@
1
+ """Bundled reference data: services.yaml, models.yaml, advisories.yaml.
2
+
3
+ Every entry in the data files carries ``source_url`` and ``verified`` — facts
4
+ are read from the vendor's/service's own pages, never guessed (AGENTS.md §6).
5
+ Loading happens once via :func:`load_bundled`; checks receive the result as a
6
+ plain argument so they stay pure.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import datetime as dt
12
+ from dataclasses import dataclass
13
+ from functools import lru_cache
14
+ from importlib import resources
15
+ from typing import Any
16
+
17
+ import yaml
18
+ from pydantic import BaseModel, Field, ValidationError
19
+
20
+ from keyfleet.model import (
21
+ Advisory,
22
+ Capability,
23
+ Interface,
24
+ Key,
25
+ Ledger,
26
+ LedgerError,
27
+ StrictModel,
28
+ Vendor,
29
+ )
30
+
31
+ _URL_PATTERN = r"^https?://\S+$"
32
+
33
+
34
+ class CapacityRule(StrictModel):
35
+ """Discoverable-credential capacity for a firmware range (lt exclusive, ge inclusive)."""
36
+
37
+ firmware_lt: str | None = None
38
+ firmware_ge: str | None = None
39
+ capacity: int = Field(ge=0)
40
+
41
+
42
+ class KeyModelInfo(StrictModel):
43
+ """One hardware-key model family in models.yaml."""
44
+
45
+ id: str
46
+ vendor: Vendor
47
+ family: str = Field(
48
+ min_length=1,
49
+ description="Case-insensitive prefix matched against Key.model; longest family wins.",
50
+ )
51
+ capabilities: list[Capability] | None = None
52
+ interfaces: list[Interface] | None = None
53
+ discoverable_capacity: int | list[CapacityRule] | None = None
54
+ source_url: str = Field(pattern=_URL_PATTERN)
55
+ verified: dt.date
56
+ notes: str = ""
57
+
58
+
59
+ class ServiceInfo(StrictModel):
60
+ """One service entry in services.yaml."""
61
+
62
+ name: str = Field(min_length=1)
63
+ security_settings_url: str | None = Field(None, pattern=_URL_PATTERN)
64
+ max_keys: int | None = Field(None, ge=0)
65
+ fido2_discoverable: bool | None = None
66
+ notes: str = ""
67
+ source_url: str = Field(pattern=_URL_PATTERN)
68
+ verified: dt.date
69
+
70
+
71
+ class BundledData(BaseModel):
72
+ """All bundled reference data, loaded once and passed around."""
73
+
74
+ services: dict[str, ServiceInfo] = Field(default_factory=dict)
75
+ models: list[KeyModelInfo] = Field(default_factory=list)
76
+ advisories: list[Advisory] = Field(default_factory=list)
77
+
78
+
79
+ def _read_data_yaml(name: str) -> Any:
80
+ file = resources.files("keyfleet").joinpath("data", name)
81
+ try:
82
+ text = file.read_text(encoding="utf-8")
83
+ except FileNotFoundError as exc:
84
+ raise LedgerError(f"bundled data file {name} is missing from the package") from exc
85
+ return yaml.safe_load(text)
86
+
87
+
88
+ def example_ledger_text() -> str:
89
+ """The packaged example ledger (mirrors keyfleet.example.yaml at the repo root)."""
90
+ return resources.files("keyfleet").joinpath("data", "example.yaml").read_text(encoding="utf-8")
91
+
92
+
93
+ @lru_cache(maxsize=1)
94
+ def load_bundled() -> BundledData:
95
+ """Load and validate the three bundled data files."""
96
+ try:
97
+ return BundledData.model_validate(
98
+ {
99
+ "services": (_read_data_yaml("services.yaml") or {}).get("services", {}),
100
+ "models": (_read_data_yaml("models.yaml") or {}).get("models", []),
101
+ "advisories": (_read_data_yaml("advisories.yaml") or {}).get("advisories", []),
102
+ }
103
+ )
104
+ except ValidationError as exc:
105
+ raise LedgerError(f"bundled data is invalid (packaging bug):\n{exc}") from exc
106
+
107
+
108
+ def services_markdown(bundled: BundledData) -> str:
109
+ """The generated docs/SERVICES.md content."""
110
+ lines = [
111
+ "# Services",
112
+ "",
113
+ "Generated from `src/keyfleet/data/services.yaml` — do not edit by hand;",
114
+ "regenerate with `uv run python scripts/gen_services_md.py`.",
115
+ "",
116
+ "Every fact comes from the service's own page (*source*), read on the",
117
+ "*verified* date. `—` means the service documents no such fact; `?` means",
118
+ "the page does not say either way.",
119
+ "",
120
+ "| Service | Security-key settings | Max keys | Passkeys | Source | Verified |",
121
+ "|---|---|---|---|---|---|",
122
+ ]
123
+ for service_id, service in bundled.services.items():
124
+ settings = (
125
+ f"[settings]({service.security_settings_url})" if service.security_settings_url else "—"
126
+ )
127
+ max_keys = "—" if service.max_keys is None else str(service.max_keys)
128
+ passkeys = {True: "yes", False: "no", None: "?"}[service.fido2_discoverable]
129
+ lines.append(
130
+ f"| {service.name} (`{service_id}`) | {settings} | {max_keys} "
131
+ f"| {passkeys} | [source]({service.source_url}) | {service.verified} |"
132
+ )
133
+ lines += ["", f"{len(bundled.services)} services."]
134
+ return "\n".join(lines) + "\n"
135
+
136
+
137
+ def firmware_tuple(firmware: str) -> tuple[int, ...]:
138
+ """'5.7.1' → (5, 7, 1). Non-numeric segment suffixes are ignored ('4a' → 4)."""
139
+ parts: list[int] = []
140
+ for segment in firmware.strip().split("."):
141
+ digits = ""
142
+ for char in segment:
143
+ if char.isdigit():
144
+ digits += char
145
+ else:
146
+ break
147
+ parts.append(int(digits) if digits else 0)
148
+ return tuple(parts)
149
+
150
+
151
+ def firmware_in_range(
152
+ firmware: str, *, firmware_ge: str | None = None, firmware_lt: str | None = None
153
+ ) -> bool:
154
+ """Inclusive lower bound, exclusive upper bound; missing bounds are open."""
155
+ value = firmware_tuple(firmware)
156
+ if firmware_ge is not None and value < firmware_tuple(firmware_ge):
157
+ return False
158
+ return not (firmware_lt is not None and value >= firmware_tuple(firmware_lt))
159
+
160
+
161
+ def model_info_for_key(models: list[KeyModelInfo], key: Key) -> KeyModelInfo | None:
162
+ """The models.yaml entry for a ledger key: same vendor, longest family prefix."""
163
+ if not key.model:
164
+ return None
165
+ name = key.model.strip().lower()
166
+ candidates = [
167
+ info
168
+ for info in models
169
+ if info.vendor is key.vendor and name.startswith(info.family.lower())
170
+ ]
171
+ if not candidates:
172
+ return None
173
+ return max(candidates, key=lambda info: len(info.family))
174
+
175
+
176
+ @dataclass(frozen=True, slots=True)
177
+ class AdvisoryMatch:
178
+ """One ledger key against the advisory list."""
179
+
180
+ key_id: str
181
+ key_label: str
182
+ firmware: str | None # None → the key needs `firmware:` set to evaluate
183
+ advisories: tuple[Advisory, ...]
184
+
185
+ @property
186
+ def needs_firmware(self) -> bool:
187
+ return self.firmware is None
188
+
189
+
190
+ def match_advisories(ledger: Ledger, bundled: BundledData) -> list[AdvisoryMatch]:
191
+ """Match every ledger key against bundled + ledger advisories.
192
+
193
+ Ledger advisories extend the bundled list; on an id collision the ledger
194
+ entry wins. A key with no firmware whose vendor has advisories on file
195
+ yields a needs-firmware entry (brief §9). Keys of vendors with no
196
+ advisories are omitted entirely.
197
+ """
198
+ merged: dict[str, Advisory] = {advisory.id: advisory for advisory in bundled.advisories}
199
+ for advisory in ledger.advisories:
200
+ merged[advisory.id] = advisory
201
+ by_vendor: dict[Vendor, list[Advisory]] = {}
202
+ for advisory in merged.values():
203
+ by_vendor.setdefault(advisory.vendor, []).append(advisory)
204
+
205
+ matches: list[AdvisoryMatch] = []
206
+ for key in ledger.keys:
207
+ candidates = by_vendor.get(key.vendor, [])
208
+ if not candidates:
209
+ continue
210
+ if key.firmware is None:
211
+ matches.append(AdvisoryMatch(key.id, key.label, None, ()))
212
+ continue
213
+ matched = tuple(
214
+ sorted(
215
+ (
216
+ advisory
217
+ for advisory in candidates
218
+ if firmware_in_range(
219
+ key.firmware,
220
+ firmware_ge=advisory.affects.firmware_ge,
221
+ firmware_lt=advisory.affects.firmware_lt,
222
+ )
223
+ ),
224
+ key=lambda advisory: advisory.id,
225
+ )
226
+ )
227
+ if matched:
228
+ matches.append(AdvisoryMatch(key.id, key.label, key.firmware, matched))
229
+ return matches
230
+
231
+
232
+ def discoverable_capacity(info: KeyModelInfo, firmware: str | None) -> int | None:
233
+ """Resolve a key's discoverable-credential capacity, or None if indeterminate."""
234
+ rules = info.discoverable_capacity
235
+ if rules is None or isinstance(rules, int):
236
+ return rules
237
+ if firmware is None:
238
+ capacities = {rule.capacity for rule in rules}
239
+ return capacities.pop() if len(capacities) == 1 else None
240
+ for rule in rules:
241
+ if firmware_in_range(firmware, firmware_ge=rule.firmware_ge, firmware_lt=rule.firmware_lt):
242
+ return rule.capacity
243
+ return None
keyfleet/checks.py ADDED
@@ -0,0 +1,263 @@
1
+ """Pure check functions over a validated :class:`~keyfleet.model.Ledger`.
2
+
3
+ Checks take the loaded ledger and return findings; no file or terminal I/O
4
+ happens here (AGENTS.md §5). Rendering lives in :mod:`keyfleet.report`.
5
+ Core rules: brief §9.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from enum import StrEnum
12
+
13
+ from keyfleet.bundled import BundledData, discoverable_capacity, model_info_for_key
14
+ from keyfleet.model import Account, KeyStatus, Ledger, RegistrationType
15
+
16
+
17
+ class Level(StrEnum):
18
+ FAIL = "FAIL"
19
+ WARN = "WARN"
20
+ INFO = "INFO"
21
+
22
+
23
+ _LEVEL_ORDER = {Level.FAIL: 0, Level.WARN: 1, Level.INFO: 2}
24
+
25
+
26
+ @dataclass(frozen=True, slots=True)
27
+ class Finding:
28
+ """One actionable result of one check."""
29
+
30
+ level: Level
31
+ check: str # machine-readable check id, e.g. "min-keys"
32
+ message: str
33
+ account_id: str | None = None
34
+ key_id: str | None = None
35
+
36
+
37
+ #: Key statuses that count toward an account's coverage (brief §9): a lost or
38
+ #: retired key is not a backup.
39
+ COVERAGE_STATUSES = frozenset({KeyStatus.ACTIVE, KeyStatus.SPARE})
40
+
41
+
42
+ def covering_key_ids(ledger: Ledger, account: Account) -> set[str]:
43
+ """Distinct keys registered on ``account`` whose status counts as coverage."""
44
+ status_by_id = {key.id: key.status for key in ledger.keys}
45
+ return {
46
+ registration.key
47
+ for registration in account.registrations
48
+ if status_by_id[registration.key] in COVERAGE_STATUSES
49
+ }
50
+
51
+
52
+ def check_min_keys(ledger: Ledger) -> list[Finding]:
53
+ """FAIL for every account with fewer counted keys than ``policy.min_keys[tier]``."""
54
+ findings: list[Finding] = []
55
+ for account in ledger.accounts:
56
+ required = ledger.policy.min_keys.for_tier(account.tier)
57
+ have = len(covering_key_ids(ledger, account))
58
+ if have < required:
59
+ noun = "hardware key" if have == 1 else "hardware keys"
60
+ findings.append(
61
+ Finding(
62
+ level=Level.FAIL,
63
+ check="min-keys",
64
+ message=(
65
+ f'{account.tier} "{account.label}" has {have} {noun} registered; '
66
+ f"policy requires {required}"
67
+ ),
68
+ account_id=account.id,
69
+ )
70
+ )
71
+ return findings
72
+
73
+
74
+ def check_lost_retired(ledger: Ledger) -> list[Finding]:
75
+ """FAIL for every lost/retired key still carrying registrations.
76
+
77
+ Whoever holds the key can still use those credentials — de-register them
78
+ (``keyfleet lost KEY`` prints the ordered checklist).
79
+ """
80
+ accounts_using: dict[str, int] = {}
81
+ for account in ledger.accounts:
82
+ for key_id in {registration.key for registration in account.registrations}:
83
+ accounts_using[key_id] = accounts_using.get(key_id, 0) + 1
84
+ findings: list[Finding] = []
85
+ for key in ledger.keys:
86
+ if key.status not in (KeyStatus.LOST, KeyStatus.RETIRED):
87
+ continue
88
+ count = accounts_using.get(key.id, 0)
89
+ if count:
90
+ noun = "account" if count == 1 else "accounts"
91
+ findings.append(
92
+ Finding(
93
+ level=Level.FAIL,
94
+ check="lost-retired",
95
+ message=(
96
+ f"Key {key.id} is {key.status.value.upper()} but still registered "
97
+ f"on {count} {noun} → run: keyfleet lost {key.id}"
98
+ ),
99
+ key_id=key.id,
100
+ )
101
+ )
102
+ return findings
103
+
104
+
105
+ def check_weak_factors(ledger: Ledger) -> list[Finding]:
106
+ """WARN for every factor an account carries that its tier's policy warns against."""
107
+ findings: list[Finding] = []
108
+ for account in ledger.accounts:
109
+ warned = ledger.policy.warn_factors.get(account.tier, [])
110
+ findings.extend(
111
+ Finding(
112
+ level=Level.WARN,
113
+ check="weak-factor",
114
+ message=f'{account.tier} "{account.label}" lists {factor.value} as a factor',
115
+ account_id=account.id,
116
+ )
117
+ for factor in account.other_factors
118
+ if factor in warned
119
+ )
120
+ return findings
121
+
122
+
123
+ def check_spare_unregistered(ledger: Ledger) -> list[Finding]:
124
+ """WARN for spare keys registered nowhere — an unregistered spare is not a backup."""
125
+ registered = {
126
+ registration.key for account in ledger.accounts for registration in account.registrations
127
+ }
128
+ return [
129
+ Finding(
130
+ level=Level.WARN,
131
+ check="spare-unregistered",
132
+ message=(
133
+ f"Spare key {key.id} is registered nowhere "
134
+ "(a spare that isn't registered is not a backup)"
135
+ ),
136
+ key_id=key.id,
137
+ )
138
+ for key in ledger.keys
139
+ if key.status is KeyStatus.SPARE and key.id not in registered
140
+ ]
141
+
142
+
143
+ def check_recovery_codes(ledger: Ledger) -> list[Finding]:
144
+ """INFO when a tier that requires recovery codes has an account without a stored pointer."""
145
+ required = set(ledger.policy.require_recovery_codes_for)
146
+ findings: list[Finding] = []
147
+ for account in ledger.accounts:
148
+ if account.tier not in required:
149
+ continue
150
+ pointer = account.recovery_codes
151
+ if pointer is None:
152
+ detail = "has no recovery-code pointer"
153
+ elif not pointer.stored:
154
+ detail = "marks recovery codes as not stored"
155
+ else:
156
+ continue
157
+ findings.append(
158
+ Finding(
159
+ level=Level.INFO,
160
+ check="recovery-codes",
161
+ message=(
162
+ f'{account.tier} "{account.label}" {detail} '
163
+ f"(policy requires recovery codes for {account.tier})"
164
+ ),
165
+ account_id=account.id,
166
+ )
167
+ )
168
+ return findings
169
+
170
+
171
+ #: Discoverable-credential usage fractions at which capacity findings appear.
172
+ CAPACITY_INFO_AT = 0.5
173
+ CAPACITY_WARN_AT = 0.9
174
+
175
+
176
+ def check_capacity(ledger: Ledger, bundled: BundledData) -> list[Finding]:
177
+ """Discoverable-credential usage per key vs models.yaml capacity.
178
+
179
+ Ledger-based estimate — it can undercount (registrations made outside the
180
+ ledger are invisible). INFO from 50% usage, WARN from 90%.
181
+ """
182
+ counts: dict[str, int] = {}
183
+ for account in ledger.accounts:
184
+ for registration in account.registrations:
185
+ if registration.type is RegistrationType.FIDO2_DISCOVERABLE:
186
+ counts[registration.key] = counts.get(registration.key, 0) + 1
187
+ findings: list[Finding] = []
188
+ for key in ledger.keys:
189
+ count = counts.get(key.id, 0)
190
+ if not count:
191
+ continue
192
+ info = model_info_for_key(bundled.models, key)
193
+ capacity = discoverable_capacity(info, key.firmware) if info else None
194
+ if not capacity:
195
+ continue
196
+ usage = count / capacity
197
+ if usage >= CAPACITY_WARN_AT:
198
+ level, action = Level.WARN, "nearly full; free slots or add a key"
199
+ elif usage >= CAPACITY_INFO_AT:
200
+ level, action = Level.INFO, "plan capacity"
201
+ else:
202
+ continue
203
+ findings.append(
204
+ Finding(
205
+ level=level,
206
+ check="capacity",
207
+ message=(
208
+ f"{key.id}: {count}/{capacity} discoverable credentials "
209
+ f"(ledger count) — {action}"
210
+ ),
211
+ key_id=key.id,
212
+ )
213
+ )
214
+ return findings
215
+
216
+
217
+ def check_unknown_service(ledger: Ledger, bundled: BundledData) -> list[Finding]:
218
+ """WARN when an account's service id names no bundled service ("other" is exempt).
219
+
220
+ Usually a typo; a correct id buys the account working links in
221
+ ``keyfleet lost`` and ``keyfleet report``.
222
+ """
223
+ return [
224
+ Finding(
225
+ level=Level.WARN,
226
+ check="unknown-service",
227
+ message=(
228
+ f'account "{account.label}": service "{account.service}" is not in the '
229
+ 'bundled services.yaml — typo, or use "other" (contributions welcome)'
230
+ ),
231
+ account_id=account.id,
232
+ )
233
+ for account in ledger.accounts
234
+ if account.service != "other" and account.service not in bundled.services
235
+ ]
236
+
237
+
238
+ #: Ledger-only checks, in run order.
239
+ ALL_CHECKS = (
240
+ check_min_keys,
241
+ check_lost_retired,
242
+ check_weak_factors,
243
+ check_spare_unregistered,
244
+ check_recovery_codes,
245
+ )
246
+
247
+ #: Checks that also need the bundled reference data.
248
+ DATA_CHECKS = (check_capacity, check_unknown_service)
249
+
250
+
251
+ def run_checks(ledger: Ledger, bundled: BundledData | None = None) -> list[Finding]:
252
+ """Run every check; findings ordered FAIL → WARN → INFO, stable within a level.
253
+
254
+ Without ``bundled``, the data-driven checks (capacity, unknown service)
255
+ are skipped.
256
+ """
257
+ findings: list[Finding] = []
258
+ for check in ALL_CHECKS:
259
+ findings.extend(check(ledger))
260
+ if bundled is not None:
261
+ for data_check in DATA_CHECKS:
262
+ findings.extend(data_check(ledger, bundled))
263
+ return sorted(findings, key=lambda finding: _LEVEL_ORDER[finding.level])