codeimprint 0.5.0a11__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.
@@ -0,0 +1,341 @@
1
+ """Ownership claims (ADR 0002, D4): build claim documents and verify a project's ownership
2
+ listing.
3
+
4
+ Verification fetches every record the listing relies on and checks each receipt (format 4)
5
+ like any seal: digest, typed ID, owner signatures, attestation, log inclusion and timestamps.
6
+ It then checks the claim rules (every listed key signed; the claimant's identity was valid at
7
+ level email or higher, or an organisation's disclosed domain identity) and **recomputes which
8
+ seals each claim covers** from the seals' own receipts (their chain signer and time), the claim
9
+ keys, membership windows, assignments and contributor agreements. The result must equal the
10
+ server's listing.
11
+
12
+ What this cannot show: that the server listed every record (a record left out of the listing
13
+ is not seen here; the transparency log is where it would show), that the server checked the
14
+ e-mail, GitHub or DNS evidence correctly, organisation key windows (taken from the
15
+ organisation's public page), or whether a claim is legally valid.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import hashlib
21
+ import secrets
22
+ from pathlib import Path
23
+
24
+ from . import receipt, trust
25
+ from .api import ApiError, NetworkError
26
+ from .imprint_file import canonical
27
+ from .receipt import FAIL, PASS, WARN, Check
28
+
29
+ CLAIM = "codeimprint.claim.v1"
30
+ CLAIM_KEY = "codeimprint.claim-key.v1"
31
+ ASSIGNMENT = "codeimprint.assignment.v1"
32
+ CONTRIBUTOR = "codeimprint.contributor.v1"
33
+ REVOCATION = "codeimprint.revocation.v1"
34
+ MEMBER = "codeimprint.org-member.v1"
35
+ MEMBER_END = "codeimprint.org-member-end.v1"
36
+ IDENTITY = "codeimprint.identity.v1"
37
+ ASSIGNING = ("cla", "employment", "contract", "assignment")
38
+ PERSON_LEVELS = ("email", "vcs")
39
+ DISCLAIMER = ("An ownership claim records what the claimant states and the evidence they "
40
+ "committed. It is not a legal determination of ownership.")
41
+
42
+
43
+ # -- documents ---------------------------------------------------------------------------
44
+ def document_commitment(path: Path) -> dict:
45
+ """A salted commitment to a supporting document. Only the commitment is sent; keep the
46
+ salt (and the file) to reveal it later."""
47
+ file_hash = hashlib.sha256(Path(path).read_bytes()).hexdigest()
48
+ salt = trust.b64e(secrets.token_bytes(32))
49
+ commitment = hashlib.sha256(canonical({"salt": salt, "sha256": file_hash})).hexdigest()
50
+ return {"commitment": commitment, "salt": salt, "sha256": file_hash, "file": str(path)}
51
+
52
+
53
+ def check_commitment(path: Path, salt: str, commitment: str) -> bool:
54
+ file_hash = hashlib.sha256(Path(path).read_bytes()).hexdigest()
55
+ return hashlib.sha256(canonical({"salt": salt, "sha256": file_hash})).hexdigest() == \
56
+ commitment
57
+
58
+
59
+ def digest(document: dict) -> str:
60
+ return hashlib.sha256(canonical(document)).hexdigest()
61
+
62
+
63
+ # -- coverage ----------------------------------------------------------------------------
64
+ def within(window: dict, at: str) -> bool:
65
+ return ((window.get("valid_from") is None or window["valid_from"] <= at)
66
+ and (window.get("valid_until") is None or at < window["valid_until"]))
67
+
68
+
69
+ def coverage(seals: list[dict], claims: list[dict]) -> dict[str, set[tuple[str, str]]]:
70
+ """``{seal: {(claim, reason)}}`` from seals ({seal, signer, attested_at, suspect}) and
71
+ claims ({id, windows, end}). The rule (D4): the seal's chain signer was an authorised key
72
+ of the claim at the seal's time, the claim had not ended, and the key was not reported
73
+ compromised by then."""
74
+ out: dict[str, set[tuple[str, str]]] = {}
75
+ for s in seals:
76
+ hits = set()
77
+ if s["signer"] and not s["suspect"]:
78
+ for c in claims:
79
+ if c["end"] and s["attested_at"] >= c["end"]:
80
+ continue
81
+ why = next((w for w in c["windows"] if w["fingerprint"] == s["signer"]
82
+ and within(w, s["attested_at"])), None)
83
+ if why:
84
+ hits.add((c["id"], why["reason"]))
85
+ out[s["seal"]] = hits
86
+ return out
87
+
88
+
89
+ class _Verifier:
90
+ def __init__(self, client, keys: list[dict], anchors=None):
91
+ self.client, self.keys, self.anchors = client, keys, anchors
92
+ self.cache: dict[str, dict] = {}
93
+ self.checks: list[Check] = []
94
+
95
+ def fail(self, name: str, detail: str) -> None:
96
+ self.checks.append(Check(name, FAIL, detail))
97
+
98
+ def record(self, rid: str) -> dict | None:
99
+ """A verified record receipt (fetched once), or None after a FAIL."""
100
+ if rid in self.cache:
101
+ return self.cache[rid]
102
+ try:
103
+ rec = self.client.public_record(rid)
104
+ except (ApiError, NetworkError) as exc:
105
+ self.fail(rid, f"cannot fetch: {exc}")
106
+ self.cache[rid] = None
107
+ return None
108
+ bad = [c for c in receipt.verify(rec, self.keys, anchors=self.anchors)
109
+ if c.status == FAIL]
110
+ if bad:
111
+ self.fail(rid, "; ".join(f"{c.name}: {c.detail}" for c in bad))
112
+ rec = None
113
+ self.cache[rid] = rec
114
+ return rec
115
+
116
+
117
+ def _at(rec: dict) -> str:
118
+ return rec["attestation"]["statement"]["attested_at"]
119
+
120
+
121
+ def _sigs(rec: dict) -> set[tuple[str, str]]:
122
+ return {(s["role"], s["fingerprint"]) for s in rec.get("signatures") or []}
123
+
124
+
125
+ def verify(client, project: str, keys: list[dict], anchors=None) -> tuple[dict, list[Check]]:
126
+ """Fetch the ownership listing (every page) and verify it. Returns the listing and the
127
+ checks; any FAIL means the listing cannot be relied on."""
128
+ view, after = None, None
129
+ while True:
130
+ page = client.public_ownership(project, after)
131
+ if view is None:
132
+ view = page
133
+ else:
134
+ view["seals"] += page["seals"]
135
+ if page.get("next") is None:
136
+ break
137
+ after = page["next"]
138
+ v = _Verifier(client, keys, anchors)
139
+
140
+ # Seals: the signer and time come from each seal's own verified receipt.
141
+ seals, status_cache = [], {}
142
+ for s in view["seals"]:
143
+ try:
144
+ rec = client.public_imprint(s["seal"])
145
+ except (ApiError, NetworkError) as exc:
146
+ v.fail(f"seal {s['seal']}", f"cannot fetch: {exc}")
147
+ continue
148
+ bad = [c for c in receipt.verify(rec, keys, anchors=anchors) if c.status == FAIL]
149
+ signer = receipt.chain_signer(rec)
150
+ if bad:
151
+ v.fail(f"seal {s['seal']}", "; ".join(f"{c.name}: {c.detail}" for c in bad))
152
+ continue
153
+ if (signer, _at(rec)) != (s["signer"], s["attested_at"]):
154
+ v.fail(f"seal {s['seal']}", "the listing misstates the seal's signer or time")
155
+ continue
156
+ if signer not in status_cache:
157
+ status_cache[signer] = client.key_identity(signer).get("compromised_since")
158
+ since = status_cache[signer]
159
+ seals.append({"seal": s["seal"], "signer": signer, "attested_at": _at(rec),
160
+ "suspect": bool(since and _at(rec) >= since)})
161
+
162
+ claims = []
163
+ for c in view["claims"]:
164
+ rules = _claim(v, c, project)
165
+ if rules:
166
+ claims.append(rules)
167
+ if any(ch.status == FAIL for ch in v.checks):
168
+ return view, v.checks
169
+ mine = coverage(seals, claims)
170
+ theirs = {s["seal"]: {(x["claim"], x["reason"]) for x in s["covered_by"]}
171
+ for s in view["seals"]}
172
+ wrong = [s for s in mine if mine[s] != theirs.get(s)]
173
+ if wrong:
174
+ v.fail("coverage", f"{len(wrong)} seal(s) are not covered as the server lists them "
175
+ f"(first: {wrong[0]})")
176
+ else:
177
+ hit = sum(1 for s in mine.values() if s)
178
+ v.checks.append(Check("coverage", PASS, f"{len(mine)} seal(s), {hit} covered by a "
179
+ "claim; recomputed here and equal to the "
180
+ "server's listing"))
181
+ if view.get("disclaimer") != DISCLAIMER:
182
+ v.checks.append(Check("wording", WARN, "the listing does not carry the ownership "
183
+ "disclaimer"))
184
+ return view, v.checks
185
+
186
+
187
+ def _claim(v: _Verifier, c: dict, project: str) -> dict | None:
188
+ """Check one claim and the records behind it; returns its coverage rules."""
189
+ name = f"claim {c['id']}"
190
+ rec = v.record(c["id"])
191
+ if rec is None:
192
+ return None
193
+ doc, at = rec["record"]["document"], _at(rec)
194
+ if rec["record"]["type"] != CLAIM or doc.get("project") != project:
195
+ v.fail(name, "not a claim on this project")
196
+ return None
197
+ if (doc["basis"], doc["effective_at"], doc["share"], doc["keys"]) != (
198
+ c["basis"], c["effective_at"], c["share"],
199
+ sorted({w["fingerprint"] for w in c["authorised"] if w["reason"] == "claim-key"
200
+ and w["record"] == c["id"]}) if doc["claimant"]["kind"] == "person"
201
+ else doc["keys"]):
202
+ v.fail(name, "the listing misstates the claim")
203
+ return None
204
+ claimant = doc["claimant"]
205
+ idt = v.record(claimant["identity"])
206
+ if idt is None:
207
+ return None
208
+ idoc = idt["record"]["document"]
209
+ if idt["record"]["type"] != IDENTITY or not (idoc["valid_from"] <= at < idoc["valid_until"]):
210
+ v.fail(name, "the claimant's identity record was not valid when the claim was made")
211
+ return None
212
+ status = v.client.key_identity(idoc["key"])
213
+ revoked = {r["id"]: r for r in status.get("revocations") or []}
214
+ entry = next((i for i in status.get("identities") or [] if i["id"] == idt["imprint_id"]),
215
+ None)
216
+ if entry and entry.get("revoked_by") and revoked.get(entry["revoked_by"], {}).get(
217
+ "effective_at", "9999") <= at:
218
+ v.fail(name, "the claimant's identity was revoked before the claim")
219
+ return None
220
+ expected = {("claimant", idoc["key"])} | {("authorised", k) for k in doc["keys"]
221
+ if k != idoc["key"]}
222
+ if _sigs(rec) != expected:
223
+ v.fail(name, "not signed by exactly the claimant and every listed key (consent)")
224
+ return None
225
+ windows = []
226
+ if claimant["kind"] == "person":
227
+ if idoc["level"] not in PERSON_LEVELS or idoc["key"] not in doc["keys"]:
228
+ v.fail(name, "a person's claim needs an identity at level email or higher on one "
229
+ "of its keys")
230
+ return None
231
+ windows += [{"fingerprint": k, "valid_from": None, "valid_until": None,
232
+ "reason": "claim-key"} for k in doc["keys"]]
233
+ else:
234
+ org = v.client.public_org(claimant["org"])
235
+ if idoc["level"] != "domain" or idoc.get("org") != claimant["org"]:
236
+ v.fail(name, "an organisation claims with its own domain identity")
237
+ return None
238
+ dom = next((d for d in org["domains"] if d["identity"] == idt["imprint_id"]
239
+ and d.get("disclosure")), None)
240
+ dsc = v.record(dom["disclosure"]) if dom else None
241
+ if dsc is None:
242
+ v.fail(name, "the organisation's domain was not disclosed (D9)")
243
+ return None
244
+ ddoc = dsc["record"]["document"]
245
+ if hashlib.sha256(canonical({"salt": ddoc["salt"], "value": ddoc["value"]})) \
246
+ .hexdigest() != idoc["subject_commitment"] or ddoc["record"] != idt[
247
+ "imprint_id"] or _at(dsc) > at:
248
+ v.fail(name, "the disclosure does not open the identity's commitment, or came "
249
+ "after the claim")
250
+ return None
251
+ active = sorted(k["fingerprint"] for k in org["keys"] if within(k, at))
252
+ if doc["keys"] != active:
253
+ v.fail(name, "the claim does not list exactly the organisation's keys at that time")
254
+ return None
255
+ windows += [{"fingerprint": k["fingerprint"], "valid_from": k["valid_from"],
256
+ "valid_until": k["valid_until"], "reason": "org-key"} for k in org["keys"]]
257
+ if doc["basis"] == "employer":
258
+ for m in org["members"]:
259
+ if m.get("revoked_by"):
260
+ continue
261
+ mrec = v.record(m["membership"])
262
+ if mrec is None:
263
+ return None
264
+ mdoc = mrec["record"]["document"]
265
+ until = None
266
+ if m.get("ended_by"):
267
+ end = v.record(m["ended_by"])
268
+ if end is None:
269
+ return None
270
+ edoc = end["record"]["document"]
271
+ if edoc.get("membership") != m["membership"]:
272
+ v.fail(name, "a membership end record is for another membership")
273
+ return None
274
+ until = edoc["valid_until"]
275
+ if (mdoc["org"], mdoc["member_key"], mdoc["valid_from"], until) != (
276
+ claimant["org"], m["member_key"], m["valid_from"], m["valid_until"]):
277
+ v.fail(name, "the organisation page misstates a membership window")
278
+ return None
279
+ windows.append({"fingerprint": mdoc["member_key"], "valid_from":
280
+ mdoc["valid_from"], "valid_until": until,
281
+ "reason": "membership"})
282
+ end = None
283
+ for rid in c["records"]:
284
+ r = v.record(rid)
285
+ if r is None:
286
+ return None
287
+ rtype, d = r["record"]["type"], r["record"]["document"]
288
+ if rtype == CLAIM_KEY and d.get("claim") == c["id"]:
289
+ by = [fp for role, fp in _sigs(r) if role == "authorised"]
290
+ if not by or not any(w["fingerprint"] == by[0] and w["reason"] == "claim-key"
291
+ and within(w, _at(r)) for w in windows):
292
+ v.fail(name, f"{rid} is not signed by a key authorised at that time")
293
+ return None
294
+ if d["action"] == "add":
295
+ if ("key", d["key"]) not in _sigs(r):
296
+ v.fail(name, f"{rid}: the added key did not consent")
297
+ return None
298
+ if not any(w["fingerprint"] == d["key"] for w in windows):
299
+ windows.append({"fingerprint": d["key"], "valid_from": _at(r),
300
+ "valid_until": None, "reason": "claim-key"})
301
+ else:
302
+ for w in windows:
303
+ if w["fingerprint"] == d["key"] and w["reason"] == "claim-key" and \
304
+ w["valid_until"] is None:
305
+ w["valid_until"] = _at(r)
306
+ elif rtype == REVOCATION and d.get("revokes") == c["id"]:
307
+ holder = [fp for role, fp in _sigs(r) if role == "holder"]
308
+ if holder and holder != [d["key"]]:
309
+ v.fail(name, f"{rid} is signed by a key other than the one it names")
310
+ return None
311
+ end = min(end or d["effective_at"], d["effective_at"])
312
+ elif rtype == ASSIGNMENT and d.get("to_claim") == c["id"]:
313
+ if ("assignor", d["from_key"]) not in _sigs(r):
314
+ v.fail(name, f"{rid}: the contributor did not sign the assignment")
315
+ return None
316
+ windows.append({"fingerprint": d["from_key"], "valid_from": d["from"],
317
+ "valid_until": d["until"], "reason": "assignment"})
318
+ elif rtype == CONTRIBUTOR and (d.get("agreement") or {}).get("assigns_to") == c["id"]:
319
+ ag = d["agreement"]
320
+ if ("contributor", d["contributor_key"]) not in _sigs(r) or \
321
+ ag["kind"] not in ASSIGNING:
322
+ v.fail(name, f"{rid}: not an assigning agreement signed by the contributor")
323
+ return None
324
+ windows.append({"fingerprint": d["contributor_key"], "valid_from": ag["from"],
325
+ "valid_until": ag.get("until"), "reason": "contributor-agreement"})
326
+ elif rtype not in (MEMBER, MEMBER_END):
327
+ v.fail(name, f"{rid} is listed but does not belong to this claim")
328
+ return None
329
+ if (end is None) != (c["status"] == "active") or (end and end != c["ended_at"]):
330
+ v.fail(name, "the listing misstates whether or when the claim ended")
331
+ return None
332
+ level = f"level {idoc['level']}" + (f", DNSSEC {idoc['dnssec']}" if idoc.get("dnssec")
333
+ else "")
334
+ v.checks.append(Check(name, PASS, f"{c['label']} ({doc['basis']}, {level}); signed by "
335
+ f"{len(expected)} key(s); "
336
+ + ("active" if end is None else f"ended {end}")))
337
+ return {"id": c["id"], "windows": windows, "end": end}
338
+
339
+
340
+ def fingerprint_of(key) -> str:
341
+ return trust.fingerprint(trust.public_raw(key))
@@ -0,0 +1,129 @@
1
+ """Choosing which files leave the machine, and packing them into a zip.
2
+
3
+ Only source files the server reads are sent (Python in this release). Files ignored by
4
+ .gitignore (root and nested) or excluded in .imprint/config.json are never sent, nor are
5
+ symlinks, version-control folders and virtual environments.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import fnmatch
11
+ import io
12
+ import json
13
+ import os
14
+ import zipfile
15
+ from dataclasses import dataclass, field
16
+ from pathlib import Path
17
+
18
+ import pathspec
19
+
20
+ CONFIG_DIR = ".imprint"
21
+ CONFIG_FILE = "config.json"
22
+ SOURCE_SUFFIXES = (".py",)
23
+ SKIP_DIRS = {
24
+ ".git", ".hg", ".svn", ".imprint", "__pycache__", ".venv", "venv", "env", "node_modules",
25
+ "build", "dist", "site-packages", ".tox", ".mypy_cache", ".pytest_cache", ".ruff_cache",
26
+ }
27
+ DEFAULT_EXCLUDE = [
28
+ "vendor/*", "third_party/*", "node_modules/*", "*/site-packages/*", "*/migrations/*",
29
+ "build/*", "dist/*",
30
+ ]
31
+ _ZIP_TIME = (1980, 1, 1, 0, 0, 0) # fixed, so the same files give the same zip
32
+
33
+
34
+ def load_config(root: Path) -> dict:
35
+ path = root / CONFIG_DIR / CONFIG_FILE
36
+ if not path.is_file():
37
+ return {}
38
+ try:
39
+ return json.loads(path.read_text(encoding="utf-8"))
40
+ except ValueError as exc:
41
+ raise ValueError(f"{path} is not valid JSON: {exc}") from exc
42
+
43
+
44
+ def _excluded(rel: str, patterns: list[str]) -> bool:
45
+ """Glob on the project-relative POSIX path; ``*`` also crosses folders (``vendor/*``)."""
46
+ return any(fnmatch.fnmatchcase(rel, p) for p in patterns)
47
+
48
+
49
+ @dataclass
50
+ class Selection:
51
+ root: Path
52
+ files: list[str] = field(default_factory=list) # project-relative POSIX paths
53
+ total_bytes: int = 0
54
+ ignored: int = 0
55
+
56
+ @property
57
+ def count(self) -> int:
58
+ return len(self.files)
59
+
60
+
61
+ def select_files(root: str | Path, suffixes: tuple[str, ...] | None = SOURCE_SUFFIXES,
62
+ use_excludes: bool = True) -> Selection:
63
+ """The files to send: ``suffixes`` (None: every file, for the line count) that
64
+ .gitignore and (``use_excludes``) .imprint/config.json do not leave out."""
65
+ root = Path(root).resolve()
66
+ if not root.is_dir():
67
+ raise FileNotFoundError(f"no such folder: {root}")
68
+ excludes = list(load_config(root).get("exclude", [])) if use_excludes else []
69
+ ignores: list[tuple[str, pathspec.GitIgnoreSpec]] = [] # (folder prefix, its .gitignore)
70
+ sel = Selection(root=root)
71
+
72
+ def ignored(rel: str, is_dir: bool) -> bool:
73
+ for prefix, spec in ignores:
74
+ if prefix and not rel.startswith(prefix):
75
+ continue
76
+ sub = rel[len(prefix):] + ("/" if is_dir else "")
77
+ if spec.match_file(sub):
78
+ return True
79
+ return False
80
+
81
+ for dirpath, dirnames, filenames in os.walk(root):
82
+ here = Path(dirpath)
83
+ rel_dir = here.relative_to(root).as_posix()
84
+ prefix = "" if rel_dir == "." else rel_dir + "/"
85
+ gitignore = here / ".gitignore"
86
+ if gitignore.is_file():
87
+ lines = gitignore.read_text(encoding="utf-8", errors="replace").splitlines()
88
+ ignores.append((prefix, pathspec.GitIgnoreSpec.from_lines(lines)))
89
+ keep = []
90
+ for d in sorted(dirnames):
91
+ rel = prefix + d
92
+ if d in SKIP_DIRS or (here / d).is_symlink() or ignored(rel, True) \
93
+ or _excluded(rel + "/", excludes):
94
+ sel.ignored += 1
95
+ continue
96
+ keep.append(d)
97
+ dirnames[:] = keep
98
+ for name in sorted(filenames):
99
+ rel = prefix + name
100
+ path = here / name
101
+ if (suffixes is not None and not name.endswith(suffixes)) or path.is_symlink():
102
+ continue
103
+ if ignored(rel, False) or _excluded(rel, excludes):
104
+ sel.ignored += 1
105
+ continue
106
+ sel.files.append(rel)
107
+ sel.total_bytes += path.stat().st_size
108
+ sel.files.sort()
109
+ return sel
110
+
111
+
112
+ def build_zip(sel: Selection) -> bytes:
113
+ buf = io.BytesIO()
114
+ with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
115
+ for rel in sel.files:
116
+ info = zipfile.ZipInfo(rel, date_time=_ZIP_TIME)
117
+ info.compress_type = zipfile.ZIP_DEFLATED
118
+ info.external_attr = 0o644 << 16
119
+ zf.writestr(info, (sel.root / rel).read_bytes())
120
+ return buf.getvalue()
121
+
122
+
123
+ def human_size(n: int) -> str:
124
+ size = float(n)
125
+ for unit in ("bytes", "kB", "MB", "GB"):
126
+ if size < 1000 or unit == "GB":
127
+ return f"{int(size)} {unit}" if unit == "bytes" else f"{size:.1f} {unit}"
128
+ size /= 1000
129
+ return f"{n} bytes"