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.
codeimprint_cli/cli.py ADDED
@@ -0,0 +1,2128 @@
1
+ """The `imprint` command: login, init, seal, check and verify."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import getpass
7
+ import hashlib
8
+ import json
9
+ import os
10
+ import sys
11
+ import time
12
+ import webbrowser
13
+ from datetime import datetime, timezone
14
+ from pathlib import Path
15
+
16
+ from . import (
17
+ __version__,
18
+ chainseal,
19
+ config,
20
+ gitrepo,
21
+ imprint_file,
22
+ linecount,
23
+ ownership,
24
+ project,
25
+ receipt,
26
+ trust,
27
+ )
28
+ from .api import ApiError, Client, NetworkError
29
+ from .tsa_roots import PINNED_ROOTS
30
+
31
+ DEFAULT_FAIL_ABOVE = 0.8
32
+ DISCLAIMER = "Similarity is evidence for review, not proof of copying."
33
+ SEAL_NOTE = ("A seal proves that this exact Imprint was registered at that time and has not "
34
+ "changed since; it does not prove who wrote the code.")
35
+
36
+
37
+ def verify_url(imprint_id: str, hub: str | None) -> str | None:
38
+ """The seal's page on the hub (config.hub_url), or None when no hub is known."""
39
+ return f"{hub}/i/{imprint_id}" if hub else None
40
+
41
+
42
+ NO_HUB = ("no hub URL known for this server: run `imprint login` (the server tells it), or "
43
+ "pass --hub-url")
44
+
45
+
46
+ def _remember_hub(url: str, published: dict | None = None) -> None:
47
+ """Cache the hub the server reports in its keys file (never fails the command)."""
48
+ try:
49
+ published = published if published is not None else Client(url).published_keys()
50
+ except (ApiError, NetworkError):
51
+ return
52
+ config.save_hub(url, (published or {}).get("public_url"))
53
+
54
+
55
+ def _verify_line(iid: str, hub: str | None, local: object) -> str:
56
+ link = verify_url(iid, hub)
57
+ return f"Verify {link} or imprint verify {local}" if link else \
58
+ f"Verify imprint verify {local} ({NO_HUB})"
59
+
60
+ HANDLING = {
61
+ "seal": "The server unpacks the files, seals them and deletes the code immediately (also "
62
+ "on errors). Only the Imprint is kept; it holds no file or function names.",
63
+ "check": "The server unpacks the files, compares them with the reference and deletes the "
64
+ "code immediately (also on errors). Nothing about this code is kept.",
65
+ }
66
+
67
+ WORKFLOW = """\
68
+ # Added by `imprint init` — CodeImprint by Mudrika
69
+ #
70
+ # Seals the code on pushes to main and on tags (the seal and its receipt are kept as a build
71
+ # artifact), and checks pull requests against a reference Imprint on your CodeImprint server.
72
+ # It installs the client from PyPI (pip install --pre codeimprint) and runs `imprint`.
73
+ # Secrets: CODEIMPRINT_URL (your server, for example https://api.codeimprint.ai; a repository
74
+ # variable of that name works too), CODEIMPRINT_TOKEN, and CODEIMPRINT_DEVELOPER_KEY (the
75
+ # contents of your developer key file) to sign seals. Variable: CODEIMPRINT_REFERENCE (an
76
+ # Imprint ID) for pull request checks. The code is uploaded, processed and deleted at once.
77
+ # Network and server problems are warnings, never failures; without CODEIMPRINT_URL (as in
78
+ # pull requests from forks) the job is skipped with a notice.
79
+ name: CodeImprint
80
+
81
+ on:
82
+ push:
83
+ branches: [main]
84
+ tags: ["*"]
85
+ pull_request:
86
+
87
+ permissions:
88
+ contents: read
89
+
90
+ env:
91
+ CODEIMPRINT_URL: ${{ secrets.CODEIMPRINT_URL || vars.CODEIMPRINT_URL }}
92
+ CODEIMPRINT_TOKEN: ${{ secrets.CODEIMPRINT_TOKEN }}
93
+
94
+ jobs:
95
+ seal:
96
+ if: github.event_name == 'push'
97
+ runs-on: ubuntu-latest
98
+ env:
99
+ CODEIMPRINT_DEVELOPER_KEY: ${{ secrets.CODEIMPRINT_DEVELOPER_KEY }}
100
+ KIND: ${{ startsWith(github.ref, 'refs/tags/') && 'release' || 'push' }}
101
+ steps:
102
+ - id: settings
103
+ run: |
104
+ if [ -n "$CODEIMPRINT_URL" ]; then echo "ready=true" >> "$GITHUB_OUTPUT"
105
+ else echo "::notice::CodeImprint: no CODEIMPRINT_URL secret, sealing skipped"; fi
106
+ - if: steps.settings.outputs.ready == 'true'
107
+ uses: actions/checkout@v7
108
+ - if: steps.settings.outputs.ready == 'true'
109
+ uses: actions/setup-python@v7
110
+ with:
111
+ python-version: "3.12"
112
+ - if: steps.settings.outputs.ready == 'true'
113
+ run: python -m pip install --disable-pip-version-check --quiet --pre codeimprint
114
+ - if: steps.settings.outputs.ready == 'true'
115
+ run: imprint seal . --yes --kind "$KIND"
116
+ - if: steps.settings.outputs.ready == 'true'
117
+ uses: actions/upload-artifact@v7
118
+ with:
119
+ name: codeimprint-seal-${{ github.sha }}
120
+ path: |
121
+ .imprint/baseline.imprint
122
+ .imprint/baseline.receipt.json
123
+ .imprint/server-keys.json
124
+ if-no-files-found: warn
125
+
126
+ check:
127
+ if: github.event_name == 'pull_request' && vars.CODEIMPRINT_REFERENCE != ''
128
+ runs-on: ubuntu-latest
129
+ env:
130
+ CODEIMPRINT_REFERENCE: ${{ vars.CODEIMPRINT_REFERENCE }}
131
+ steps:
132
+ - id: settings
133
+ run: |
134
+ if [ -n "$CODEIMPRINT_URL" ]; then echo "ready=true" >> "$GITHUB_OUTPUT"
135
+ else echo "::notice::CodeImprint: no CODEIMPRINT_URL secret, check skipped"; fi
136
+ - if: steps.settings.outputs.ready == 'true'
137
+ uses: actions/checkout@v7
138
+ - if: steps.settings.outputs.ready == 'true'
139
+ uses: actions/setup-python@v7
140
+ with:
141
+ python-version: "3.12"
142
+ - if: steps.settings.outputs.ready == 'true'
143
+ run: python -m pip install --disable-pip-version-check --quiet --pre codeimprint
144
+ - if: steps.settings.outputs.ready == 'true'
145
+ run: >-
146
+ imprint check . --yes --reference "$CODEIMPRINT_REFERENCE"
147
+ --fail-above "%(fail_above)s" --output codeimprint-result.json
148
+ """
149
+
150
+
151
+ class Abort(Exception):
152
+ """A user-facing error: print it and exit 2."""
153
+
154
+
155
+ class SealFailed(Abort):
156
+ """Sealing failed after the upload; a warning (exit 0) in CI, an error elsewhere."""
157
+
158
+
159
+ def _say(*lines: str) -> None:
160
+ print("\n".join(lines))
161
+
162
+
163
+ def _ci() -> bool:
164
+ return os.environ.get("GITHUB_ACTIONS") == "true"
165
+
166
+
167
+ def _warn(message: str) -> None:
168
+ if _ci():
169
+ print(f"::warning::CodeImprint: {message}")
170
+ print(f"imprint: warning: {message}", file=sys.stderr)
171
+
172
+
173
+ def _client(args, fallback_url: str | None = None) -> Client:
174
+ settings = config.resolve(getattr(args, "url", None))
175
+ url = settings.url or fallback_url
176
+ if not url:
177
+ raise Abort("no server configured: run `imprint login --url <server>` or set "
178
+ "CODEIMPRINT_URL")
179
+ return Client(url, settings.token if settings.url else None)
180
+
181
+
182
+ def _prepare_upload(path: str, action: str, client: Client, assume_yes: bool) -> bytes:
183
+ sel = project.select_files(path)
184
+ if sel.count == 0:
185
+ raise Abort(f"no Python files to send under {sel.root} (check .gitignore and "
186
+ ".imprint/config.json excludes)")
187
+ data = project.build_zip(sel)
188
+ _say(f"About to send to {client.url}:",
189
+ f" {sel.count} Python file(s), {project.human_size(sel.total_bytes)} "
190
+ f"({project.human_size(len(data))} zipped) from {sel.root}",
191
+ f" {sel.ignored} path(s) left out by .gitignore, .imprint/config.json or defaults",
192
+ f" {HANDLING[action]}")
193
+ if not assume_yes:
194
+ if not sys.stdin.isatty():
195
+ raise Abort("not sending without confirmation: pass --yes (for example in CI)")
196
+ if input("Continue? [y/N] ").strip().lower() not in {"y", "yes"}:
197
+ raise Abort("cancelled; nothing was sent")
198
+ return data
199
+
200
+
201
+ def receipt_path_for(imprint_path: Path) -> Path:
202
+ """``baseline.imprint`` -> ``baseline.receipt.json`` in the same folder."""
203
+ return imprint_path.with_name(imprint_path.name.removesuffix(".imprint") + ".receipt.json")
204
+
205
+
206
+ # -- commands ------------------------------------------------------------------------------
207
+ PROVIDER_ORDER = ("github", "gitlab", "google", "microsoft", "email")
208
+
209
+
210
+ def _signin(args, url: str) -> int:
211
+ """Sign in through the browser (or an e-mail link); the terminal gets a short-lived
212
+ session once the browser confirms the code shown here."""
213
+ client = Client(url)
214
+ try:
215
+ enabled = {k for k, v in client.login_providers().items() if v.get("enabled")}
216
+ except (ApiError, NetworkError) as exc:
217
+ raise Abort(f"this server does not offer sign-in: {exc}") from exc
218
+ provider = args.provider or next((p for p in PROVIDER_ORDER if p in enabled), None)
219
+ if provider is None or provider not in enabled:
220
+ raise Abort(f"sign-in with {provider or 'any provider'} is not configured on {url}"
221
+ + (f" (available: {', '.join(sorted(enabled))})" if enabled else ""))
222
+ email = args.email
223
+ if provider == "email" and not email:
224
+ email = input("E-mail address: ").strip()
225
+ try:
226
+ start = client.login_start(provider, email)
227
+ except (ApiError, NetworkError) as exc:
228
+ raise Abort(f"sign-in failed: {exc}") from exc
229
+ _say(f"Code {start['user_code']} (the browser must show the same code)")
230
+ if provider == "email":
231
+ _say(f"Sent a sign-in link to {email}: open it, check the code and confirm")
232
+ else:
233
+ _say(f"Open {start['verification_url']}")
234
+ if not args.no_browser:
235
+ webbrowser.open(start["verification_url"])
236
+ deadline = time.monotonic() + start["expires_in"]
237
+ while time.monotonic() < deadline:
238
+ time.sleep(start.get("interval", 2))
239
+ try:
240
+ done = client.login_poll(start["login_id"], start["device_secret"])
241
+ except ApiError as exc:
242
+ raise Abort(f"sign-in failed: {exc.message}") from exc
243
+ except NetworkError as exc:
244
+ raise Abort(str(exc)) from exc
245
+ if done.get("status") == "complete":
246
+ path = config.save_session(url, done)
247
+ who = done.get("handle") or done.get("email") or provider
248
+ _say(f"Signed in with {provider} as {who}, until {done['expires_at']}",
249
+ f"Session saved to {path} (readable by you only)")
250
+ try:
251
+ me = Client(url, done["token"]).me()
252
+ except (ApiError, NetworkError):
253
+ me = {}
254
+ _remember_hub(url)
255
+ if me.get("terms_required"):
256
+ _say(f"Terms {_terms_hint(url)}")
257
+ return 0
258
+ raise Abort("the sign-in expired: run `imprint login` again")
259
+
260
+
261
+ def cmd_login(args) -> int:
262
+ url = (args.url or os.environ.get("CODEIMPRINT_URL") or config.load_user_config().get("url")
263
+ or (input("CodeImprint server URL: ") if not args.token_stdin else ""))
264
+ url = url.strip().rstrip("/")
265
+ if not url.startswith(("https://", "http://")):
266
+ raise Abort("the URL must start with https:// (or http:// for a local server)")
267
+ if args.cf_access:
268
+ client_id = input("Cloudflare Access service token, Client ID: ").strip()
269
+ client_secret = getpass.getpass("Client Secret (not shown): ").strip()
270
+ if not client_id or not client_secret:
271
+ raise Abort("both the Client ID and the Client Secret are needed")
272
+ path = config.save_cf_access(url, client_id, client_secret)
273
+ _say(f"Saved the Cloudflare Access service token for {url} to {path} (readable by you "
274
+ "only; sent to that server only)")
275
+ if args.public_url:
276
+ _warn("--public-url is no longer used: the hub URL comes from the server; pass "
277
+ "--hub-url to `imprint seal` or set CODEIMPRINT_HUB_URL to override it")
278
+ if not (args.token_stdin or args.api_token):
279
+ return _signin(args, url)
280
+ token = sys.stdin.readline().strip() if args.token_stdin else getpass.getpass(
281
+ "API token (leave empty if the server needs none): ").strip()
282
+ try:
283
+ health = Client(url, token).health()
284
+ except (ApiError, NetworkError) as exc:
285
+ raise Abort(str(exc)) from exc
286
+ if health.get("auth") and not token:
287
+ _warn("this server requires a token; seal and check will fail without one")
288
+ path = config.save_user_config(url, token)
289
+ _remember_hub(url)
290
+ _say(f"Saved {url} to {path}",
291
+ f"Server {health.get('version', '?')}, {health.get('key_mode', '?')} key, "
292
+ f"auth: {health.get('auth_mode', 'unknown')}")
293
+ if url.startswith("http://") and "localhost" not in url and "127.0.0.1" not in url:
294
+ _warn("this URL is not HTTPS: your code and token would travel unencrypted")
295
+ return 0
296
+
297
+
298
+ def cmd_init(args) -> int:
299
+ root = Path(args.path).resolve()
300
+ if not root.is_dir():
301
+ raise Abort(f"no such folder: {root}")
302
+ cfg_path = root / project.CONFIG_DIR / project.CONFIG_FILE
303
+ existing = project.load_config(root)
304
+ cfg = {"version": 1, "exclude": existing.get("exclude", project.DEFAULT_EXCLUDE),
305
+ "fail_above": existing.get("fail_above", DEFAULT_FAIL_ABOVE),
306
+ "reference": existing.get("reference")}
307
+ written, skipped = [], []
308
+
309
+ key, key_note = None, ""
310
+ if not args.no_key:
311
+ key = trust.load_developer_key()
312
+ key_note = "found"
313
+ if key is None:
314
+ key, key_note = trust.create_developer_key(), "created"
315
+ cfg["developer"] = existing.get("developer") or trust.fingerprint(trust.public_raw(key))
316
+ elif existing.get("developer"):
317
+ cfg["developer"] = existing["developer"]
318
+
319
+ def write(path: Path, text: str) -> None:
320
+ rel = path.relative_to(root).as_posix()
321
+ if path.exists() and not args.force:
322
+ skipped.append(rel)
323
+ return
324
+ path.parent.mkdir(parents=True, exist_ok=True)
325
+ path.write_text(text, encoding="utf-8")
326
+ written.append(rel)
327
+
328
+ if cfg_path.exists() and not args.force and "developer" in cfg and "developer" not in existing:
329
+ existing["developer"] = cfg["developer"] # record the key in our own file
330
+ cfg_path.write_text(json.dumps(existing, indent=2) + "\n", encoding="utf-8")
331
+ _say(f"updated {cfg_path.relative_to(root).as_posix()} (developer key)")
332
+ else:
333
+ write(cfg_path, json.dumps(cfg, indent=2) + "\n")
334
+ if not args.no_ci:
335
+ write(root / ".github" / "workflows" / "codeimprint.yml",
336
+ WORKFLOW % {"fail_above": cfg["fail_above"]})
337
+ for rel in written:
338
+ _say(f"created {rel}")
339
+ for rel in skipped:
340
+ _say(f"kept {rel} (exists; --force to overwrite)")
341
+ if key is not None:
342
+ fp = trust.fingerprint(trust.public_raw(key))
343
+ _say("", f"Developer key {fp} ({key_note}): {trust.developer_key_path()}",
344
+ " It stays on this machine: never uploaded, never in the repository. Back it up.",
345
+ " To sign seals in CI, store the file's contents as the secret "
346
+ "CODEIMPRINT_DEVELOPER_KEY.")
347
+ if cfg.get("developer") != fp:
348
+ _warn(f"this project expects developer key {cfg['developer']}, not {fp}")
349
+ if key is not None and not args.no_project:
350
+ _init_project(args, root, cfg_path, key)
351
+ if key is not None:
352
+ _offer_identity(args, key)
353
+ _say("", "Next: `imprint seal` after each commit (or `imprint hook install --on commit`).")
354
+ return 0
355
+
356
+
357
+ def default_project_name(root: Path) -> str:
358
+ """The repository's ``owner/repo`` from its origin remote, else the folder's name."""
359
+ if gitrepo.is_repo(root):
360
+ _host, name = gitrepo.repository_name(gitrepo.remote_url(root))
361
+ if name:
362
+ return name
363
+ return Path(root).resolve().name or "project"
364
+
365
+
366
+ def _init_project(args, root: Path, cfg_path: Path, key) -> None:
367
+ """Create the project and its genesis seal, so its start is fixed before any code."""
368
+ current = project.load_config(root)
369
+ settings = config.resolve(getattr(args, "url", None))
370
+ if current.get("project_id"):
371
+ _say(f"Project {current['project_id']} (already created)")
372
+ if getattr(args, "name", None) and settings.url:
373
+ _rename(Client(settings.url, settings.token), current["project_id"], key,
374
+ args.name, True)
375
+ return
376
+ if not settings.url:
377
+ _say("", "No server configured: run `imprint login`, then `imprint init` again to "
378
+ "create the project and its genesis seal.")
379
+ return
380
+ client = Client(settings.url, settings.token)
381
+ name = getattr(args, "name", None) or default_project_name(root)
382
+ try:
383
+ made = chainseal.create_project(client, key, name)
384
+ except (ApiError, NetworkError) as exc:
385
+ _warn(f"the project was not created ({exc}); run `imprint init` again later")
386
+ return
387
+ g = made["genesis"]
388
+ current["project_id"] = made["project_id"]
389
+ cfg_path.parent.mkdir(parents=True, exist_ok=True)
390
+ cfg_path.write_text(json.dumps(current, indent=2) + "\n", encoding="utf-8")
391
+ folder = root / project.CONFIG_DIR
392
+ imprint_file.save(g["imprint"], folder / "genesis.imprint")
393
+ (folder / "genesis.receipt.json").write_text(
394
+ json.dumps(g["receipt"], indent=1, sort_keys=True) + "\n", encoding="utf-8")
395
+ if gitrepo.is_repo(root):
396
+ chainseal.record(root, g, "", None)
397
+ _say("", f"Project {made['project_id']} (in .imprint/config.json)",
398
+ f"Name {name} (shown on the project's public page; change it with "
399
+ "`imprint project rename`, or --name)",
400
+ f"Genesis {g['id']}, sealed {g['receipt']['attestation']['statement']['attested_at']} "
401
+ f"(log entry #{g['receipt']['log']['index']}): the project's chain starts here",
402
+ "Saved .imprint/genesis.imprint and .imprint/genesis.receipt.json (commit them)")
403
+
404
+
405
+ def _rename(client: Client, project_id: str, key, name: str, public: bool) -> dict:
406
+ statement = {"type": "codeimprint.project-name.v1", "project": project_id,
407
+ "name": " ".join(name.split()), "public": public}
408
+ try:
409
+ out = client.rename_project(project_id, statement["name"], public,
410
+ chainseal.public_key(key), chainseal.sign(key, statement))
411
+ except (ApiError, NetworkError) as exc:
412
+ raise Abort(f"the project was not renamed: {exc}") from exc
413
+ _say(f"Name {out['name']} ({'public' if out['public'] else 'shown to you only'})")
414
+ return out
415
+
416
+
417
+ # -- onboarding: the key's identity (CIM-BE-19-v1) -----------------------------------------------
418
+ IDENTITY_WHY = ("The hub's \"My projects\" finds your projects through your key's verified "
419
+ "identity, and so do ownership claims.")
420
+
421
+
422
+ def _offer_identity(args, key, once: bool = False) -> None:
423
+ """If the developer key has no valid identity on this server, offer to bind it now (asked
424
+ in a terminal only, never silently; in CI or without a terminal, a hint). ``once``: ask at
425
+ most once per server and key (the first seal)."""
426
+ settings = config.resolve(getattr(args, "url", None))
427
+ if not settings.url or _ci():
428
+ return
429
+ fp = trust.fingerprint(trust.public_raw(key))
430
+ marker = config.config_dir() / "identity-offered.json"
431
+ try:
432
+ asked = json.loads(marker.read_text()) if marker.is_file() else {}
433
+ except (OSError, ValueError):
434
+ asked = {}
435
+ tag = f"{settings.url}|{fp}"
436
+ if once and tag in asked:
437
+ return
438
+ try:
439
+ status = Client(settings.url, settings.token).key_identity(fp)
440
+ except (ApiError, NetworkError):
441
+ return # never in the way of init or a seal
442
+ if any(i.get("status") == "valid" for i in status.get("identities") or []):
443
+ return
444
+ try:
445
+ marker.parent.mkdir(parents=True, exist_ok=True)
446
+ marker.write_text(json.dumps({**asked, tag: _now_iso()}, indent=1) + "\n")
447
+ except OSError:
448
+ pass
449
+ _say("", f"Identity your developer key {fp} has no verified identity on this server.",
450
+ f" {IDENTITY_WHY}")
451
+ if not sys.stdin.isatty():
452
+ _say(" Bind it with: imprint identity bind --level email")
453
+ return
454
+ if input(" Bind it to your verified e-mail address now (imprint identity bind --level "
455
+ "email)? [y/N] ").strip().lower() not in {"y", "yes"}:
456
+ _say(" Not bound. Later: imprint identity bind --level email")
457
+ return
458
+ try:
459
+ _bind(args, key, "email", False)
460
+ except Abort as exc:
461
+ _warn(f"not bound: {exc}")
462
+
463
+
464
+ # -- hooks, chain, export ------------------------------------------------------------------
465
+ def cmd_hook(args) -> int:
466
+ root = Path(args.path)
467
+ if not gitrepo.is_repo(root):
468
+ raise Abort("not a Git repository")
469
+ hook = gitrepo.git_dir(root) / "hooks" / chainseal.HOOK_FILES[args.on]
470
+ if args.action == "install":
471
+ if hook.exists() and chainseal.HOOK_MARK not in hook.read_text(errors="replace"):
472
+ raise Abort(f"{hook} already exists: add this line to it instead:\n"
473
+ f' "{sys.executable}" -m codeimprint_cli hook run --on {args.on} || true')
474
+ hook.parent.mkdir(parents=True, exist_ok=True)
475
+ hook.write_text(chainseal.hook_script(sys.executable, args.on), encoding="utf-8")
476
+ hook.chmod(0o755)
477
+ _say(f"Installed {hook}: every {args.on} is sealed (kind {args.on}). The hook never "
478
+ "blocks: offline, it queues the seal and sends it on the next run.")
479
+ return 0
480
+ return _hook_run(args, root)
481
+
482
+
483
+ def _hook_run(args, root: Path) -> int:
484
+ """Seal queued commits and HEAD; never fails: problems queue the seal with one warning."""
485
+ def warn(message: str) -> int:
486
+ print(f"imprint: warning: {message}", file=sys.stderr)
487
+ return 0
488
+
489
+ try:
490
+ cfg = project.load_config(gitrepo.toplevel(root))
491
+ if not cfg.get("project_id"):
492
+ return warn("this repository has no CodeImprint project; run `imprint init`")
493
+ key = trust.load_developer_key()
494
+ if key is None:
495
+ return warn("no developer key; run `imprint init`")
496
+ settings = config.resolve(None)
497
+ head = gitrepo.resolve(root, "HEAD")
498
+ except (gitrepo.GitError, ValueError, OSError) as exc:
499
+ return warn(f"seal skipped ({exc})")
500
+ queue = chainseal.load_queue(root)
501
+ if head not in {q["commit"] for q in queue}:
502
+ queue.append({"commit": head, "kind": args.on})
503
+ if not settings.url:
504
+ chainseal.save_queue(root, queue)
505
+ return warn(f"no server configured; {len(queue)} seal(s) queued")
506
+ client = Client(settings.url, settings.token, timeout=60)
507
+ for i, item in enumerate(queue):
508
+ try:
509
+ out = chainseal.seal_commit(client, root, cfg["project_id"], item["commit"],
510
+ item["kind"], key, cfg.get("exclude"))
511
+ except Exception as exc: # noqa: BLE001 — a hook never blocks a commit or push
512
+ chainseal.save_queue(root, queue[i:])
513
+ return warn(f"seal of {item['commit'][:12]} queued ({len(queue) - i} waiting): "
514
+ f"{exc}")
515
+ if "already" in out:
516
+ continue
517
+ chainseal.record(root, out["result"], item["commit"], out["info"]["ref"])
518
+ print(f"imprint: sealed {item['commit'][:12]} as {out['result']['id']} "
519
+ f"({out['uploaded']} file(s) uploaded)", file=sys.stderr)
520
+ chainseal.save_queue(root, [])
521
+ return 0
522
+
523
+
524
+ def cmd_chain(args) -> int:
525
+ root = Path(args.path)
526
+ cfg = project.load_config(root) if root.is_dir() else {}
527
+ project_id = args.project or cfg.get("project_id")
528
+ if not project_id:
529
+ raise Abort("no project: run `imprint init` (or pass --project)")
530
+ local = {c["seal"]: c for c in chainseal.local_chain(root)} if gitrepo.is_repo(root) else {}
531
+ client = _client(args)
532
+ seals, after = [], None
533
+ try:
534
+ while True:
535
+ page = client.public_chain(project_id, after)
536
+ seals += page["seals"]
537
+ if page.get("next") is None:
538
+ break
539
+ after = page["next"]
540
+ except (ApiError, NetworkError) as exc:
541
+ raise Abort(f"cannot read the chain: {exc}") from exc
542
+ _say(f"Project {project_id} ({len(seals)} seal(s) on the server, "
543
+ f"{len(local)} made from this clone)", "",
544
+ f"{'log':>6} {'seal':<27} {'kind':<8} {'attested':<21} {'commit':<12} parent")
545
+ for s in seals:
546
+ commit = (local.get(s["seal"], {}).get("commit") or "")[:12]
547
+ parent = ", ".join(s["parents"]) or "-"
548
+ _say(f"{s['log_index']:>6} {s['seal']:<27} {s['kind']:<8} {s['attested_at']:<21} "
549
+ f"{commit:<12} {parent}")
550
+ missing = [c for c in local if c not in {s["seal"] for s in seals}]
551
+ if missing:
552
+ _warn(f"{len(missing)} seal(s) recorded locally are not in the server's chain")
553
+ return 1
554
+ return 0
555
+
556
+
557
+ def _now_iso() -> str:
558
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
559
+
560
+
561
+ def _session_client(args) -> Client:
562
+ settings = config.resolve(getattr(args, "url", None))
563
+ if not settings.url:
564
+ raise Abort("no server configured: run `imprint login --url <server>`")
565
+ s = config.load_session(settings.url)
566
+ if not s:
567
+ raise Abort(f"not signed in to {settings.url} (or the session expired): run "
568
+ "`imprint login`")
569
+ return Client(settings.url, s["token"])
570
+
571
+
572
+ def _checked(client: Client, rec: dict, what: str) -> None:
573
+ """Verify a record receipt the server returned before showing or saving it."""
574
+ try:
575
+ keys, problems = trust.trust_server_keys(client.url, client.published_keys())
576
+ except (ApiError, NetworkError) as exc:
577
+ raise Abort(f"cannot fetch the server's keys: {exc}") from exc
578
+ if problems:
579
+ raise Abort("the server's attestation keys changed unexpectedly (" + "; ".join(problems)
580
+ + ")")
581
+ bad = _failed(receipt.verify(rec, keys, anchors=_anchors(argparse.Namespace(
582
+ tsa_certs=None))))
583
+ if bad:
584
+ raise Abort(f"the {what} does not verify (" + "; ".join(
585
+ f"{c.name}: {c.detail}" for c in bad) + ")")
586
+
587
+
588
+ def cmd_identity(args) -> int:
589
+ key = _developer_key(required=True)
590
+ fp = trust.fingerprint(trust.public_raw(key))
591
+ if args.action == "show":
592
+ client = _client(args)
593
+ try:
594
+ s = client.key_identity((args.key or fp).lower())
595
+ except (ApiError, NetworkError) as exc:
596
+ raise Abort(f"cannot read the key's identity: {exc}") from exc
597
+ _say(f"Key {s['key']}" + (" (your developer key)" if s["key"] == fp else ""))
598
+ if not s["identities"]:
599
+ _say("Identity none: run `imprint login`, then `imprint identity bind`")
600
+ for i in s["identities"]:
601
+ subject = f" {i['subject']}" if i.get("subject") else ""
602
+ state = (f"revoked by {i['revoked_by']}" if i["status"] == "revoked" else
603
+ f"{i['status']}, until {i['valid_until']}")
604
+ _say(f"Identity {i['id']} {i['level']} via {i['provider']}{subject} ({state})")
605
+ for r in s["revocations"]:
606
+ since = f", compromised since {r['compromised_since']}" \
607
+ if r.get("compromised_since") else ""
608
+ _say(f"Revoked {r['id']} {r['reason']} from {r['effective_at']}{since}")
609
+ if s.get("compromised_since"):
610
+ _warn(f"this key was reported compromised since {s['compromised_since']}: anything "
611
+ "it signed from then on is flagged")
612
+ return 0
613
+ if args.action == "bind":
614
+ _bind(args, key, args.level, args.show_email_domain)
615
+ return 0
616
+ return _revoke_identity(args, key, fp)
617
+
618
+
619
+ def _bind(args, key, level: str, show_email_domain: bool) -> dict:
620
+ """Bind the developer key to the signed-in identity (an IDT1 record)."""
621
+ sc = _session_client(args)
622
+ try:
623
+ ch = sc.identity_challenge(chainseal.public_key(key), level, show_email_domain)
624
+ rec = sc.identity_attest(ch["challenge_id"], chainseal.sign(key, ch["statement"]))
625
+ except (ApiError, NetworkError) as exc:
626
+ raise Abort(f"binding failed: {exc}") from exc
627
+ _checked(sc, rec, "identity record")
628
+ out = config.config_dir() / "identity" / f"{rec['imprint_id']}.receipt.json"
629
+ out.parent.mkdir(parents=True, exist_ok=True)
630
+ out.write_text(json.dumps(rec, indent=1, sort_keys=True) + "\n", encoding="utf-8")
631
+ doc = rec["record"]["document"]
632
+ subject = f" as {doc['subject']}" if doc.get("subject") else ""
633
+ _say(f"Identity {rec['imprint_id']}",
634
+ f"Key {doc['key']} verified at level {doc['level']} via "
635
+ f"{doc['provider']}{subject}",
636
+ f"Valid {doc['valid_from']} to {doc['valid_until']}",
637
+ f"Log entry #{rec['log']['index']}",
638
+ f"Receipt {out}",
639
+ "Public level, provider and (for GitHub/GitLab) your handle; your e-mail address "
640
+ "is never public")
641
+ return rec
642
+
643
+
644
+ def _revoke_identity(args, key, fp: str) -> int:
645
+ if args.reason != "key-compromised" and not args.record: # a compromise can name the key
646
+ raise Abort("name the identity record to revoke (IDT1-...)")
647
+ document = {"type": "codeimprint.revocation.v1", "key": fp, "reason": args.reason,
648
+ "effective_at": _now_iso()}
649
+ if args.record:
650
+ document["revokes"] = imprint_file.clean_id(args.record)
651
+ if args.compromised_since:
652
+ document["compromised_since"] = args.compromised_since
653
+ from . import records
654
+
655
+ digest = hashlib.sha256(imprint_file.canonical(document)).hexdigest()
656
+ sig = chainseal.sign(key, records.sign_statement(document["type"], digest))
657
+ client = _client(args)
658
+ try:
659
+ rec = client.revoke(document, [{"role": "holder", "public_key":
660
+ chainseal.public_key(key), "signature": sig}])
661
+ except (ApiError, NetworkError) as exc:
662
+ raise Abort(f"revocation failed: {exc}") from exc
663
+ _checked(client, rec, "revocation record")
664
+ _say(f"Revoked {rec['imprint_id']} ({args.reason}"
665
+ + (f", compromised since {args.compromised_since}" if args.compromised_since else "")
666
+ + ")", f"Log entry #{rec['log']['index']}")
667
+ return 0
668
+
669
+
670
+ # -- ownership claims (ADR 0002 steps 1.12-1.14) -------------------------------------------
671
+ WARRANTY = ("You state that this claim is truthful: the claimant warrants it. CodeImprint "
672
+ "records, verifies and timestamps the claim; it never decides who owns the code.")
673
+
674
+
675
+ def _project_id(args) -> str:
676
+ root = Path(getattr(args, "path", None) or ".")
677
+ cfg = project.load_config(root) if root.is_dir() else {}
678
+ project_id = getattr(args, "project", None) or cfg.get("project_id")
679
+ if not project_id:
680
+ raise Abort("no project: run `imprint init` (or pass --project)")
681
+ return project_id
682
+
683
+
684
+ def _signing_keys(args) -> dict:
685
+ """{fingerprint: private key}: the developer key first, then each --also-sign key file."""
686
+ from cryptography.hazmat.primitives import serialization
687
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
688
+
689
+ key = _developer_key(required=True)
690
+ out = {trust.fingerprint(trust.public_raw(key)): key}
691
+ for path in getattr(args, "also_sign", None) or []:
692
+ extra = serialization.load_pem_private_key(Path(path).read_bytes(), password=None)
693
+ if not isinstance(extra, Ed25519PrivateKey):
694
+ raise Abort(f"{path} is not an Ed25519 private key")
695
+ out[trust.fingerprint(trust.public_raw(extra))] = extra
696
+ return out
697
+
698
+
699
+ def _sign_record(key, document: dict, role: str) -> dict:
700
+ from . import records
701
+
702
+ digest = ownership.digest(document)
703
+ return {"role": role, "public_key": chainseal.public_key(key),
704
+ "signature": chainseal.sign(key, records.sign_statement(document["type"], digest))}
705
+
706
+
707
+ def _ask(args, question: str) -> None:
708
+ if args.yes:
709
+ return
710
+ if not sys.stdin.isatty():
711
+ raise Abort("not signing without confirmation: pass --yes")
712
+ if input(f"{question} [y/N] ").strip().lower() not in {"y", "yes"}:
713
+ raise Abort("cancelled")
714
+
715
+
716
+ def _claims_folder(args) -> Path:
717
+ root = Path(getattr(args, "path", None) or ".")
718
+ folder = root / ".imprint" if (root / ".imprint").is_dir() else config.config_dir()
719
+ return folder / "claims"
720
+
721
+
722
+ def _claimant_identity(client: Client, args, fp: str) -> str:
723
+ """The identity record for a person's claim, or an organisation's (--org)."""
724
+ if args.org:
725
+ org = client.public_org(args.org)
726
+ for d in org["domains"]:
727
+ if d.get("disclosed") and d["status"] == "verified":
728
+ rec = client.public_record(d["identity"])["record"]["document"]
729
+ if rec["key"] == fp:
730
+ return d["identity"]
731
+ raise Abort("the organisation has no disclosed, verified domain held by your key: "
732
+ "verify and disclose a domain first (its first claim makes it public)")
733
+ if args.identity:
734
+ return imprint_file.clean_id(args.identity)
735
+ for i in client.key_identity(fp)["identities"]:
736
+ if i["status"] == "valid" and i["level"] in ownership.PERSON_LEVELS:
737
+ return i["id"]
738
+ raise Abort("your key has no valid identity at level email or higher: run `imprint "
739
+ "login`, then `imprint identity bind`")
740
+
741
+
742
+ def cmd_claim(args) -> int:
743
+ client = _client(args)
744
+ if args.action == "show":
745
+ return _claim_show(args, client)
746
+ if args.action != "withdraw": # records need a signed-in, accepted account
747
+ client = _session_client(args)
748
+ keys = _signing_keys(args)
749
+ fp = next(iter(keys))
750
+ if args.action == "create":
751
+ return _claim_create(args, client, keys, fp)
752
+ if not args.claim:
753
+ raise Abort("name the claim (CLM1-...)")
754
+ claim_id = imprint_file.clean_id(args.claim)
755
+ try:
756
+ claim = client.public_record(claim_id)
757
+ except (ApiError, NetworkError) as exc:
758
+ raise Abort(f"cannot read the claim: {exc}") from exc
759
+ _checked(client, claim, "claim record")
760
+ project_id = claim["record"]["document"]["project"]
761
+ if args.action == "withdraw":
762
+ document = {"type": ownership.REVOCATION, "revokes": claim_id, "key": fp,
763
+ "reason": "withdrawn", "effective_at": _now_iso()}
764
+ _say(f"Withdraw {claim_id} from now (seals until now stay covered by it)")
765
+ _ask(args, "Withdraw this claim?")
766
+ try:
767
+ rec = client.revoke(document, [_sign_record(keys[fp], document, "holder")])
768
+ except (ApiError, NetworkError) as exc:
769
+ raise Abort(f"withdrawal failed: {exc}") from exc
770
+ _checked(client, rec, "withdrawal record")
771
+ _say(f"Withdrawn {rec['imprint_id']}", f"Log entry #{rec['log']['index']}")
772
+ return 0
773
+ if args.action == "add-key": # a person's claim
774
+ if len(keys) != 2:
775
+ raise Abort("add-key: pass the new key's private key file with --also-sign FILE")
776
+ new_fp, new_key = list(keys.items())[1]
777
+ document = {"type": ownership.CLAIM_KEY, "claim": claim_id, "action": "add",
778
+ "key": new_fp}
779
+ sigs = [_sign_record(keys[fp], document, "authorised"),
780
+ _sign_record(new_key, document, "key")]
781
+ else:
782
+ if not args.key:
783
+ raise Abort("retire-key: name the key's fingerprint with --key")
784
+ document = {"type": ownership.CLAIM_KEY, "claim": claim_id, "action": "retire",
785
+ "key": args.key.lower()}
786
+ sigs = [_sign_record(keys[fp], document, "authorised")]
787
+ try:
788
+ rec = client.claim_key(project_id, claim_id, document, sigs)
789
+ except (ApiError, NetworkError) as exc:
790
+ raise Abort(f"{args.action} failed: {exc}") from exc
791
+ _checked(client, rec, "claim key record")
792
+ done = "Added" if document["action"] == "add" else "Retired"
793
+ _say(f"{done:<10} key {document['key']} on {claim_id} ({rec['imprint_id']}, log entry "
794
+ f"#{rec['log']['index']})")
795
+ return 0
796
+
797
+
798
+ def _claim_create(args, client: Client, keys: dict, fp: str) -> int:
799
+ project_id = _project_id(args)
800
+ try:
801
+ identity = _claimant_identity(client, args, fp)
802
+ if args.org:
803
+ listed = sorted(k["fingerprint"] for k in client.public_org(args.org)["keys"]
804
+ if k["valid_until"] is None)
805
+ else:
806
+ listed = sorted(keys)
807
+ except (ApiError, NetworkError) as exc:
808
+ raise Abort(f"cannot prepare the claim: {exc}") from exc
809
+ documents, private = [], []
810
+ for spec in args.document or []:
811
+ kind, sep, path = spec.partition("=")
812
+ if not sep or not Path(path).is_file():
813
+ raise Abort(f"--document wants KIND=FILE (got {spec!r})")
814
+ c = ownership.document_commitment(Path(path))
815
+ documents.append({"kind": kind, "commitment": c["commitment"]})
816
+ private.append({"kind": kind, **c})
817
+ document = {"type": ownership.CLAIM, "project": project_id,
818
+ "effective_at": args.effective_at or _now_iso(),
819
+ "claimant": {"kind": "org" if args.org else "person", "identity": identity,
820
+ "org": args.org},
821
+ "keys": listed, "basis": args.basis, "share": args.share,
822
+ "documents": documents, "jurisdiction": args.jurisdiction,
823
+ "counters": [], "supersedes": None}
824
+ try:
825
+ prep = client.claim_prepare(project_id, document)
826
+ except (ApiError, NetworkError) as exc:
827
+ raise Abort(f"the server refused the claim: {exc}") from exc
828
+ missing = [s["fingerprint"] for s in prep["signers"] if s["fingerprint"] not in keys]
829
+ if missing:
830
+ raise Abort("every listed key must sign: pass --also-sign FILE for "
831
+ + ", ".join(missing))
832
+ kind = "Licence claim" if args.basis == "licensee" else "Ownership claim"
833
+ who = f"organisation {args.org}" if args.org else "you"
834
+ _say(f"{kind} on project {project_id}",
835
+ f" claimant {who} ({identity})",
836
+ f" basis {args.basis}",
837
+ f" from {document['effective_at']}",
838
+ f" keys {', '.join(listed)} (each signs)",
839
+ f" documents {len(documents)} (only salted commitments are sent)",
840
+ f" {WARRANTY}",
841
+ " Claims are public, logged and timestamped; withdrawing ends one but never "
842
+ "deletes it.")
843
+ _ask(args, "Sign and record this claim?")
844
+ sigs = [{"role": s["role"], "public_key": chainseal.public_key(keys[s["fingerprint"]]),
845
+ "signature": chainseal.sign(keys[s["fingerprint"]], prep["statement"])}
846
+ for s in prep["signers"]]
847
+ try:
848
+ rec = client.claim_finalise(project_id, document, sigs)
849
+ except (ApiError, NetworkError) as exc:
850
+ raise Abort(f"the claim was not recorded: {exc}") from exc
851
+ _checked(client, rec, "claim record")
852
+ folder = _claims_folder(args)
853
+ folder.mkdir(parents=True, exist_ok=True)
854
+ out = folder / f"{rec['imprint_id']}.receipt.json"
855
+ out.write_text(json.dumps(rec, indent=1, sort_keys=True) + "\n", encoding="utf-8")
856
+ _say(f"Claim {rec['imprint_id']}", f"Log entry #{rec['log']['index']}",
857
+ f"Receipt {out}")
858
+ if private:
859
+ salts = folder / f"{rec['imprint_id']}.documents.json"
860
+ salts.write_text(json.dumps(private, indent=1) + "\n", encoding="utf-8")
861
+ _say(f"Documents {salts} (the salts: keep this file private; with the file it "
862
+ "reveals a document, never without it)")
863
+ return 0
864
+
865
+
866
+ def _claim_show(args, client: Client) -> int:
867
+ if not args.claim:
868
+ raise Abort("name the claim (CLM1-...)")
869
+ claim_id = imprint_file.clean_id(args.claim)
870
+ try:
871
+ rec = client.public_record(claim_id)
872
+ _checked(client, rec, "claim record")
873
+ doc = rec["record"]["document"]
874
+ view = client.public_ownership(doc["project"])
875
+ except (ApiError, NetworkError) as exc:
876
+ raise Abort(f"cannot read the claim: {exc}") from exc
877
+ c = next((x for x in view["claims"] if x["id"] == claim_id), None)
878
+ share = f", share {doc['share']}" if doc["share"] else ""
879
+ _say(f"Claim {claim_id} (log entry #{rec['log']['index']}, "
880
+ f"{rec['attestation']['statement']['attested_at']})")
881
+ if c:
882
+ _say(f" {c['label']}")
883
+ _say(f"Project {doc['project']}",
884
+ f"Basis {c['basis_label'] if c else doc['basis']}",
885
+ f"From {doc['effective_at']}{share}",
886
+ f"Keys {', '.join(doc['keys'])}",
887
+ f"Documents {len(doc['documents'])} committed by salted hash")
888
+ if c:
889
+ state = c["status"] + (f" at {c['ended_at']} ({c['ended_by']})" if c["ended_at"]
890
+ else "")
891
+ _say(f"Status {state}", f"Covers {c['covered_seals']} seal(s)")
892
+ _say("", view.get("disclaimer") or ownership.DISCLAIMER)
893
+ return 0
894
+
895
+
896
+ def cmd_assign(args) -> int:
897
+ """An assignment from a contributor key to a claim: both sides sign. Run by one side, it
898
+ writes a file for the other side to complete (`--complete FILE`)."""
899
+ client = _client(args)
900
+ keys = _signing_keys(args)
901
+ if args.complete:
902
+ data = json.loads(Path(args.complete).read_text(encoding="utf-8"))
903
+ document, sigs = data["document"], data["signatures"]
904
+ else:
905
+ if not args.claim or not args.start:
906
+ raise Abort("assign: name the claim (CLM1-...) and --from TIME")
907
+ claim_id = imprint_file.clean_id(args.claim)
908
+ try:
909
+ claim = client.public_record(claim_id)
910
+ except (ApiError, NetworkError) as exc:
911
+ raise Abort(f"cannot read the claim: {exc}") from exc
912
+ _checked(client, claim, "claim record")
913
+ document = {"type": ownership.ASSIGNMENT,
914
+ "project": claim["record"]["document"]["project"],
915
+ "from_key": (args.from_key or next(iter(keys))).lower(),
916
+ "to_claim": claim_id, "from": args.start, "until": args.until}
917
+ sigs = []
918
+ roles = {s["role"] for s in sigs}
919
+ for kfp, key in keys.items():
920
+ role = "assignor" if kfp == document["from_key"] else "assignee"
921
+ if role not in roles:
922
+ sigs.append(_sign_record(key, document, role))
923
+ roles.add(role)
924
+ _say(f"Assignment of the seals by key {document['from_key']} from {document['from']} "
925
+ f"until {document['until'] or 'the claim ends'} to {document['to_claim']}")
926
+ if roles != {"assignor", "assignee"}:
927
+ out = Path(args.output or f"assignment-{ownership.digest(document)[:12]}.json")
928
+ out.write_text(json.dumps({"document": document, "signatures": sigs}, indent=1)
929
+ + "\n", encoding="utf-8")
930
+ _say(f"Signed as {', '.join(sorted(roles))}; written to {out}",
931
+ f"Next the other side runs `imprint assign --complete {out}`")
932
+ return 0
933
+ client = _session_client(args)
934
+ _ask(args, "Record this assignment?")
935
+ try:
936
+ rec = client.assignment(document["project"], document, sigs)
937
+ except (ApiError, NetworkError) as exc:
938
+ raise Abort(f"the assignment was not recorded: {exc}") from exc
939
+ _checked(client, rec, "assignment record")
940
+ _say(f"Assigned {rec['imprint_id']}", f"Log entry #{rec['log']['index']}")
941
+ return 0
942
+
943
+
944
+ def cmd_ownership(args) -> int:
945
+ client = _client(args)
946
+ project_id = _project_id(args)
947
+ try:
948
+ keys, problems = trust.trust_server_keys(client.url, client.published_keys())
949
+ if problems:
950
+ raise Abort("the server's attestation keys changed unexpectedly ("
951
+ + "; ".join(problems) + ")")
952
+ view, checks = ownership.verify(client, project_id, keys,
953
+ anchors=_anchors(argparse.Namespace(tsa_certs=None)))
954
+ except (ApiError, NetworkError) as exc:
955
+ raise Abort(f"cannot read the ownership listing: {exc}") from exc
956
+ if args.json:
957
+ print(json.dumps({"ownership": view, "checks": [c.as_dict() for c in checks]},
958
+ indent=1))
959
+ return 1 if _failed(checks) else 0
960
+ _say(f"Project {project_id}", f"Ownership {view['label']}")
961
+ for label in view.get("licence_claims") or []:
962
+ _say(f"Licence {label}")
963
+ for c in view["claims"]:
964
+ end = f", ended {c['ended_at']}" if c["ended_at"] else ""
965
+ share = f"; share {c['share']}" if c["share"] else ""
966
+ _say("", f"{c['id']} {c['label']}",
967
+ f" basis {c['basis_label']}; from {c['effective_at']}; {c['status']}{end}; "
968
+ f"covers {c['covered_seals']} seal(s){share}")
969
+ _say("", f"{'log':>6} {'seal':<27} {'signer':<16} covered by")
970
+ for s in view["seals"]:
971
+ by = ", ".join(f"{x['claim']} ({x['reason']})" for x in s["covered_by"]) or (
972
+ "not covered (key reported compromised)" if s["suspect"] else "not covered")
973
+ _say(f"{s['log_index']:>6} {s['seal']:<27} {s['signer'] or '-':<16} {by}")
974
+ for w in view.get("warnings") or []:
975
+ _warn(w)
976
+ _say("")
977
+ for c in checks:
978
+ _say(f"{c.status:<4} {c.name}: {c.detail}")
979
+ _say("", view["disclaimer"],
980
+ "Checked here: every record's receipt and signatures, the claim rules, and each "
981
+ "seal's coverage. Not checked: that the server listed every record, the evidence "
982
+ "behind identities, organisation key windows, legal validity.")
983
+ return 1 if _failed(checks) else 0
984
+
985
+
986
+ # -- organisations and contributors (ADR 0002 steps 1.9-1.12) -------------------------------
987
+ def _checked_digest(document: dict, digest: str, statement: dict) -> None:
988
+ """A prepared record is signed only if its statement is for exactly this document."""
989
+ from . import records
990
+
991
+ if ownership.digest(document) != digest or statement != records.sign_statement(
992
+ document["type"], digest):
993
+ raise Abort("the server's statement does not match the document; not signing")
994
+
995
+
996
+ def _ascii_domain(domain: str) -> str:
997
+ """The domain as the server names it: lower case, punycode for international names."""
998
+ d = domain.strip().rstrip(".").lower()
999
+ try:
1000
+ return d.encode("idna").decode("ascii")
1001
+ except UnicodeError as exc:
1002
+ raise Abort(f"not a domain name: {domain!r}") from exc
1003
+
1004
+
1005
+ def _challenge_path(org: str, domain: str) -> Path:
1006
+ return config.config_dir() / "orgs" / org / f"{domain}.challenge.json"
1007
+
1008
+
1009
+ def _org_show(client: Client, org_id: str) -> int:
1010
+ try:
1011
+ org = client.public_org(org_id)
1012
+ except (ApiError, NetworkError) as exc:
1013
+ raise Abort(f"cannot read the organisation: {exc}") from exc
1014
+ _say(f"Organisation {org['org_id']} ({org['label']})",
1015
+ f"Name {org['name'] or 'none until a verified domain is disclosed'}"
1016
+ + (f" ({org['reserved_name']})" if org.get("reserved_name") else ""))
1017
+ for k in org["keys"]:
1018
+ until = f" until {k['valid_until']}" if k["valid_until"] else ""
1019
+ _say(f"Key {k['fingerprint']} {k['role']} from {k['valid_from']}{until}")
1020
+ for d in org["domains"]:
1021
+ name = d["domain"] or "(not disclosed)"
1022
+ _say(f"Domain {name} {d['status']}, DNSSEC {d['dnssec']}")
1023
+ for w in d.get("warnings") or []:
1024
+ _warn(f"{name}: {w}")
1025
+ for m in org["members"]:
1026
+ until = f" until {m['valid_until']}" if m["valid_until"] else ""
1027
+ _say(f"Member {m['member_key']} {m['basis']} from {m['valid_from']}{until} "
1028
+ f"({m['membership']})")
1029
+ return 0
1030
+
1031
+
1032
+ def cmd_org(args) -> int:
1033
+ client = _client(args)
1034
+ if args.action == "show":
1035
+ if not args.org:
1036
+ raise Abort("name the organisation (its ID)")
1037
+ return _org_show(client, args.org)
1038
+ if args.action != "end": # records need a signed-in, accepted account
1039
+ client = _session_client(args)
1040
+ keys = _signing_keys(args)
1041
+ fp, key = next(iter(keys.items()))
1042
+ pub = chainseal.public_key(key)
1043
+ if args.action == "create":
1044
+ sc = _session_client(args)
1045
+ _say(f"Create an organisation with your key {fp} as its single admin")
1046
+ _ask(args, "Create it?")
1047
+ try:
1048
+ org = sc.org_create(pub, chainseal.sign(key, {"type": "codeimprint.org-create.v1",
1049
+ "public_key": pub}))
1050
+ except (ApiError, NetworkError) as exc:
1051
+ raise Abort(f"the organisation was not created: {exc}") from exc
1052
+ _say(f"Organisation {org['org_id']} ({org['label']})",
1053
+ f"Next imprint org domain {org['org_id']} <domain>")
1054
+ return 0
1055
+ if args.action == "approve":
1056
+ return _org_approve(args, client, key)
1057
+ if not args.org:
1058
+ raise Abort("name the organisation (its ID)")
1059
+ org = args.org
1060
+ if args.action in ("domain", "verify", "disclose") and not args.target:
1061
+ raise Abort(f"{args.action}: name the domain")
1062
+ try:
1063
+ if args.action == "domain":
1064
+ ch = client.org_domain_challenge(org, args.target, pub)
1065
+ path = _challenge_path(org, _ascii_domain(ch["domain"]))
1066
+ path.parent.mkdir(parents=True, exist_ok=True)
1067
+ path.write_text(json.dumps(ch, indent=1) + "\n", encoding="utf-8")
1068
+ _say(f"Publish this DNS TXT record for {ch['domain']}:",
1069
+ f" name {ch['txt_name']}", f" value {ch['txt_value']}",
1070
+ "Use DNSSEC on the domain if you can (pages show \"DNSSEC validated\" or "
1071
+ "\"not signed\").",
1072
+ f"Then imprint org verify {org} {ch['domain']}")
1073
+ for w in (ch.get("assessment") or {}).get("warnings") or []:
1074
+ _warn(w)
1075
+ return 0
1076
+ if args.action == "verify":
1077
+ path = _challenge_path(org, _ascii_domain(args.target))
1078
+ if not path.is_file():
1079
+ raise Abort(f"no saved challenge: run `imprint org domain {org} {args.target}` "
1080
+ "first")
1081
+ ch = json.loads(path.read_text(encoding="utf-8"))
1082
+ rec = client.org_domain_verify(org, ch["domain"], chainseal.sign(key,
1083
+ ch["statement"]))
1084
+ _checked(client, rec, "domain identity record")
1085
+ path.unlink()
1086
+ doc = rec["record"]["document"]
1087
+ _say(f"Verified {ch['domain']} for {org}: identity {rec['imprint_id']}, DNSSEC "
1088
+ f"{doc['dnssec']}",
1089
+ "Private the domain stays private until you disclose it: "
1090
+ f"imprint org disclose {org} {ch['domain']}")
1091
+ return 0
1092
+ if args.action == "disclose":
1093
+ doc = client.org_disclosure_document(org, args.target)
1094
+ _checked_digest(doc["document"], doc["digest"], doc["statement"])
1095
+ _say(f"Disclose {doc['document']['value']} for organisation {org}: from now on it "
1096
+ "is public, and the organisation is shown as \"verified domain "
1097
+ f"{doc['document']['value']}\"")
1098
+ _ask(args, "Disclose it?")
1099
+ rec = client.org_disclose(org, args.target, pub, chainseal.sign(key,
1100
+ doc["statement"]))
1101
+ _checked(client, rec, "disclosure record")
1102
+ _say(f"Disclosed {rec['imprint_id']} (log entry #{rec['log']['index']})")
1103
+ return 0
1104
+ if args.action == "join":
1105
+ return _org_join(args, client, org, keys)
1106
+ # end a membership
1107
+ if not args.target:
1108
+ raise Abort("end: name the membership (MBR1-...)")
1109
+ membership = imprint_file.clean_id(args.target)
1110
+ rec = client.public_record(membership)
1111
+ member_key = rec["record"]["document"]["member_key"]
1112
+ document = {"type": "codeimprint.org-member-end.v1", "org": org,
1113
+ "membership": membership, "valid_until": _now_iso()}
1114
+ role = "member" if fp == member_key else "admin"
1115
+ _say(f"End membership {membership} of key {member_key} now (as the {role}); "
1116
+ "its seals from now on are not covered by the organisation's claims")
1117
+ _ask(args, "End it?")
1118
+ rec = client.org_member_end(org, membership, document["valid_until"],
1119
+ [_sign_record(key, document, role)])
1120
+ _checked(client, rec, "membership end record")
1121
+ _say(f"Ended {rec['imprint_id']} (log entry #{rec['log']['index']})")
1122
+ return 0
1123
+ except (ApiError, NetworkError) as exc:
1124
+ raise Abort(f"{args.action} failed: {exc}") from exc
1125
+
1126
+
1127
+ def _org_join(args, client: Client, org: str, keys: dict) -> int:
1128
+ """The member key asks to join; a company-domain sign-in proves it alone, else the
1129
+ admin approves (here with --also-sign, or later with `imprint org approve FILE`)."""
1130
+ fp, key = next(iter(keys.items()))
1131
+ sc = _session_client(args) if args.basis != "admin-approval" else client
1132
+ p = sc.org_member_prepare(org, chainseal.public_key(key), args.basis)
1133
+ _checked_digest(p["document"], p["digest"], p["statement"])
1134
+ sigs = [{"role": "member", "public_key": chainseal.public_key(key),
1135
+ "signature": chainseal.sign(key, p["statement"])}]
1136
+ _say(f"Join organisation {org} with key {fp} ({args.basis})")
1137
+ if "admin" in p["roles"]:
1138
+ admins = [k for k in list(keys)[1:]]
1139
+ if not admins:
1140
+ out = Path(args.output or f"membership-{p['digest'][:12]}.json")
1141
+ out.write_text(json.dumps({"org": org, **p, "signatures": sigs}, indent=1) + "\n",
1142
+ encoding="utf-8")
1143
+ _say(f"Signed as member; written to {out}",
1144
+ f"Next the admin runs `imprint org approve {out}` within an hour")
1145
+ return 0
1146
+ sigs.append({"role": "admin", "public_key": chainseal.public_key(keys[admins[0]]),
1147
+ "signature": chainseal.sign(keys[admins[0]], p["statement"])})
1148
+ _ask(args, "Join?")
1149
+ rec = client.org_member_finalise(org, p["pending_id"], sigs)
1150
+ _checked(client, rec, "membership record")
1151
+ _say(f"Member {rec['imprint_id']} from {rec['record']['document']['valid_from']} "
1152
+ f"(log entry #{rec['log']['index']})")
1153
+ return 0
1154
+
1155
+
1156
+ def _org_approve(args, client: Client, key) -> int:
1157
+ if not args.org or not Path(args.org).is_file():
1158
+ raise Abort("approve: name the membership file the member wrote")
1159
+ data = json.loads(Path(args.org).read_text(encoding="utf-8"))
1160
+ _checked_digest(data["document"], data["digest"], data["statement"])
1161
+ d = data["document"]
1162
+ _say(f"Approve key {d['member_key']} as a member of {d['org']} ({d['basis']}), from "
1163
+ f"{d['valid_from']}")
1164
+ _ask(args, "Approve?")
1165
+ sigs = data["signatures"] + [{"role": "admin", "public_key": chainseal.public_key(key),
1166
+ "signature": chainseal.sign(key, data["statement"])}]
1167
+ try:
1168
+ rec = client.org_member_finalise(d["org"], data["pending_id"], sigs)
1169
+ except (ApiError, NetworkError) as exc:
1170
+ raise Abort(f"approval failed: {exc}") from exc
1171
+ _checked(client, rec, "membership record")
1172
+ _say(f"Member {rec['imprint_id']} (log entry #{rec['log']['index']})")
1173
+ return 0
1174
+
1175
+
1176
+ def cmd_contributor(args) -> int:
1177
+ """A contributor record: the contributor key and a project key sign. Run by one side, it
1178
+ writes a file for the other side to complete (`--complete FILE`)."""
1179
+ client = _client(args)
1180
+ keys = _signing_keys(args)
1181
+ private = None
1182
+ if args.complete:
1183
+ data = json.loads(Path(args.complete).read_text(encoding="utf-8"))
1184
+ document, sigs = data["document"], data["signatures"]
1185
+ else:
1186
+ if not args.role:
1187
+ raise Abort("contributor: give the contributor's --role (for example developer)")
1188
+ document = {"type": "codeimprint.contributor.v1", "project": _project_id(args),
1189
+ "contributor_key": (args.contributor_key or next(iter(keys))).lower(),
1190
+ "evidence": "signed-commits", "role": args.role}
1191
+ if args.agreement:
1192
+ if not args.start:
1193
+ raise Abort("an agreement needs --from TIME")
1194
+ agreement = {"kind": args.agreement, "from": args.start, "until": args.until}
1195
+ if args.assigns_to:
1196
+ agreement["assigns_to"] = imprint_file.clean_id(args.assigns_to)
1197
+ if args.agreement_document:
1198
+ c = ownership.document_commitment(Path(args.agreement_document))
1199
+ agreement["commitment"], private = c["commitment"], c
1200
+ document["agreement"] = agreement
1201
+ if args.share:
1202
+ document["share"] = args.share
1203
+ sigs = []
1204
+ roles = {s["role"] for s in sigs}
1205
+ for kfp, key in keys.items():
1206
+ role = "contributor" if kfp == document["contributor_key"] else "project"
1207
+ if role not in roles:
1208
+ sigs.append(_sign_record(key, document, role))
1209
+ roles.add(role)
1210
+ ag = document.get("agreement")
1211
+ _say(f"Contributor key {document['contributor_key']} ({document['role']}) on project "
1212
+ f"{document['project']}" + (f"; {ag['kind']} agreement from {ag['from']}"
1213
+ + (f", assigning to {ag['assigns_to']}"
1214
+ if ag.get("assigns_to") else "") if ag else ""))
1215
+ if private:
1216
+ folder = _claims_folder(args)
1217
+ folder.mkdir(parents=True, exist_ok=True)
1218
+ salts = folder / f"contributor-{ownership.digest(document)[:12]}.documents.json"
1219
+ salts.write_text(json.dumps([private], indent=1) + "\n", encoding="utf-8")
1220
+ _say(f"Documents {salts} (the salt: keep this file private)")
1221
+ if roles != {"contributor", "project"}:
1222
+ out = Path(args.output or f"contributor-{ownership.digest(document)[:12]}.json")
1223
+ out.write_text(json.dumps({"document": document, "signatures": sigs}, indent=1) + "\n",
1224
+ encoding="utf-8")
1225
+ _say(f"Signed as {', '.join(sorted(roles))}; written to {out}",
1226
+ f"Next the other side runs `imprint contributor --complete {out}`")
1227
+ return 0
1228
+ client = _session_client(args)
1229
+ _ask(args, "Record this contributor?")
1230
+ try:
1231
+ rec = client.contributor(document["project"], document, sigs)
1232
+ except (ApiError, NetworkError) as exc:
1233
+ raise Abort(f"the contributor record was not recorded: {exc}") from exc
1234
+ _checked(client, rec, "contributor record")
1235
+ _say(f"Recorded {rec['imprint_id']} (log entry #{rec['log']['index']})")
1236
+ return 0
1237
+
1238
+
1239
+ def cmd_export(args) -> int:
1240
+ client = _client(args)
1241
+ iid = imprint_file.clean_id(args.imprint_id)
1242
+ if not imprint_file.is_valid_id(iid):
1243
+ raise Abort(f"not a valid Imprint ID: {args.imprint_id}")
1244
+ try:
1245
+ doc = client.export(iid)
1246
+ except (ApiError, NetworkError) as exc:
1247
+ raise Abort(f"export failed: {exc}") from exc
1248
+ status = imprint_file.verify_document(doc)
1249
+ bound = doc.get("export_of", {})
1250
+ if not status["valid"] or bound.get("seal") != iid:
1251
+ raise Abort("the export does not verify or names another seal; not saved")
1252
+ rec_path = Path(args.path) / project.CONFIG_DIR / "baseline.receipt.json"
1253
+ if rec_path.is_file():
1254
+ rec = imprint_file.load(rec_path)
1255
+ if rec.get("imprint_id") == iid and bound.get("manifest_root") != rec[
1256
+ "attestation"]["statement"].get("manifest_root"):
1257
+ raise Abort("the export's manifest root differs from the sealed one; not saved")
1258
+ out = Path(args.output) if args.output else Path(f"{iid}.full.imprint")
1259
+ imprint_file.save(doc, out)
1260
+ _say(f"Exported {iid} -> {out} ({doc.get('unit_count')} units)",
1261
+ f"It is a self-contained Imprint of the seal (export_of: {bound.get('seal')}, "
1262
+ f"manifest root {bound.get('manifest_root', '')[:16]}…); the server checked that its "
1263
+ "units reproduce the sealed manifest root.")
1264
+ return 0
1265
+
1266
+
1267
+ def _failed(checks: list[receipt.Check]) -> list[receipt.Check]:
1268
+ return [c for c in checks if c.status == receipt.FAIL]
1269
+
1270
+
1271
+ def _developer_key(required: bool = False):
1272
+ try:
1273
+ key = trust.load_developer_key()
1274
+ except ValueError as exc:
1275
+ raise SealFailed(f"cannot read the developer key: {exc}") from exc
1276
+ if key is None and required:
1277
+ raise SealFailed("this project's seals are signed by your developer key, and none was "
1278
+ "found: run `imprint init` (or set CODEIMPRINT_DEVELOPER_KEY in CI)")
1279
+ return key
1280
+
1281
+
1282
+ def _accept(args, client: Client, result: dict) -> tuple[list, list, list[receipt.Check]]:
1283
+ """Trust the server's keys and check the seal before saving anything."""
1284
+ doc, iid = result["imprint"], result["id"]
1285
+ status = imprint_file.verify_document(doc)
1286
+ if not status["valid"] or status["id"] != iid:
1287
+ raise SealFailed("the server returned an Imprint that does not verify; not saved")
1288
+ if not result.get("receipt"):
1289
+ raise SealFailed("the server returned no seal receipt (it predates tamper-evident "
1290
+ "sealing); not saved")
1291
+ try:
1292
+ published = client.published_keys()
1293
+ except (ApiError, NetworkError) as exc:
1294
+ raise SealFailed(f"cannot fetch the server's keys: {exc}") from exc
1295
+ config.save_hub(client.url, published.get("public_url"))
1296
+ keys, problems = trust.trust_server_keys(client.url, published)
1297
+ if problems:
1298
+ raise SealFailed("the server's attestation keys changed unexpectedly ("
1299
+ + "; ".join(problems) + "); not saved")
1300
+ anchors = _anchors(args)
1301
+ checks = receipt.verify(result["receipt"], keys, doc, anchors=anchors)
1302
+ bad = _failed(checks)
1303
+ if bad:
1304
+ raise SealFailed("the seal does not verify (" + "; ".join(
1305
+ f"{c.name}: {c.detail}" for c in bad) + "); not saved")
1306
+ return keys, anchors, checks
1307
+
1308
+
1309
+ def _finish(args, client: Client, root: Path, result: dict, keys: list,
1310
+ checks: list[receipt.Check], signer: str | None, extra: list[str] = ()) -> int:
1311
+ """Save the Imprint, its receipt and the server keys, then print the summary."""
1312
+ doc, iid, rec = result["imprint"], result["id"], result["receipt"]
1313
+ out = (Path(args.output) if getattr(args, "output", None)
1314
+ else root / project.CONFIG_DIR / "baseline.imprint")
1315
+ imprint_file.save(doc, out)
1316
+ rec_path = receipt_path_for(out)
1317
+ rec_path.write_text(json.dumps(rec, indent=1, sort_keys=True) + "\n", encoding="utf-8")
1318
+ trust.save_project_keys(out.parent, client.url, keys)
1319
+ for warning in rec.get("warnings") or []:
1320
+ _warn(warning)
1321
+ cp, st = rec["log"]["checkpoint"], rec["attestation"]["statement"]
1322
+ stamps = [c for c in checks if c.name.startswith("timestamp")]
1323
+ parents = ", ".join(st.get("parents") or []) or "none"
1324
+ hub = config.hub_url(client.url, getattr(args, "hub_url", None))
1325
+ _say(f"Imprint {iid}",
1326
+ *([f"Kind {st['kind']} (parent: {parents})"] if "kind" in st else []),
1327
+ *extra,
1328
+ f"Units {result['units']}",
1329
+ *([f"Lines {linecount.summary(doc['lines'])}"] if doc.get("lines") else []),
1330
+ f"Sealed {st['attested_at']}, attested by {rec['attestation']['key_id']}",
1331
+ f"Log entry #{rec['log']['index']}, checkpoint of "
1332
+ f"{cp['statement']['tree_size']} entries",
1333
+ "Timestamps")
1334
+ for c in stamps:
1335
+ _say(f" {c.status:<5} {c.name.removeprefix('timestamp').strip() or '-':<10} {c.detail}")
1336
+ _say(f"Signed {'developer key ' + signer if signer else 'not signed by you'}",
1337
+ f"Saved {out}",
1338
+ f"Receipt {rec_path}",
1339
+ _verify_line(iid, hub, out))
1340
+ if hub:
1341
+ _say("", "README badge:", imprint_file.badge(iid, hub))
1342
+ _say("", SEAL_NOTE)
1343
+ _step_summary(iid, rec, stamps, signer, hub)
1344
+ return 0
1345
+
1346
+
1347
+ def _folder_lines(path: str) -> dict:
1348
+ """The line count of a folder sealed as a zip (every file type, docs/line-count.md)."""
1349
+ sel = project.select_files(path, suffixes=None, use_excludes=False)
1350
+ return linecount.count_folder(sel.root, sel.files).payload()
1351
+
1352
+
1353
+ def _seal_zip(args, client: Client, data: bytes, project_id: str | None,
1354
+ lines: dict | None = None) -> int:
1355
+ """A zip seal (kind manual): code without Git, or `--zip`. Joins the project's chain
1356
+ when the project is known."""
1357
+ key = _developer_key(required=bool(project_id))
1358
+ try:
1359
+ result = client.seal(data, name=args.name, lines=lines, project_id=project_id)
1360
+ if "pending_id" in result: # chained: sign, then finalise
1361
+ pub = chainseal.public_key(key)
1362
+ result = client.finalise(project_id, result["pending_id"], pub,
1363
+ chainseal.sign(key, result["statement"]))
1364
+ if lines is not None:
1365
+ chainseal.check_lines(lines, result)
1366
+ except (ApiError, NetworkError) as exc:
1367
+ raise SealFailed(f"sealing failed: {exc}") from exc
1368
+ except RuntimeError as exc:
1369
+ raise SealFailed(str(exc)) from exc
1370
+ if result.get("already_sealed"):
1371
+ _say(f"Already sealed: {result['id']} (this exact content was sealed before)")
1372
+ keys, _unused, checks = _accept(args, client, result)
1373
+ root = Path(args.path)
1374
+ signer = None
1375
+ if project_id:
1376
+ signer = trust.fingerprint(trust.public_raw(key))
1377
+ elif key is not None and not result.get("already_sealed"):
1378
+ expected = project.load_config(root).get("developer") if root.is_dir() else None
1379
+ signer = trust.fingerprint(trust.public_raw(key))
1380
+ if expected and signer != expected:
1381
+ _warn(f"signing with key {signer}, but this project expects {expected}")
1382
+ try:
1383
+ client.add_signature(result["id"], trust.sign_digest(key, result["receipt"]["digest"]))
1384
+ result["receipt"] = client.public_imprint(result["id"])
1385
+ except (ApiError, NetworkError) as exc:
1386
+ _warn(f"the seal is saved but not signed by you: {exc}")
1387
+ signer = None
1388
+ checks = receipt.verify(result["receipt"], keys, result["imprint"],
1389
+ anchors=_anchors(args))
1390
+ bad = _failed(checks)
1391
+ if bad:
1392
+ raise SealFailed("the signed receipt does not verify (" + "; ".join(
1393
+ f"{c.name}: {c.detail}" for c in bad) + "); not saved")
1394
+ elif key is None:
1395
+ _warn("no developer key, so this seal is not signed by you: run `imprint init` "
1396
+ "(or set CODEIMPRINT_DEVELOPER_KEY in CI)")
1397
+ return _finish(args, client, root, result, keys, checks, signer)
1398
+
1399
+
1400
+ def _confirm_upload(args, client: Client):
1401
+ def ask(count: int, size: int) -> bool:
1402
+ _say(f"About to send to {client.url}:",
1403
+ f" {count} Python file(s) the server has not fingerprinted yet "
1404
+ f"({project.human_size(size)}); everything else is already known to it",
1405
+ f" {HANDLING['seal']}")
1406
+ if args.yes:
1407
+ return True
1408
+ if not sys.stdin.isatty():
1409
+ raise Abort("not sending without confirmation: pass --yes (for example in CI)")
1410
+ return input("Continue? [y/N] ").strip().lower() in {"y", "yes"}
1411
+ return ask
1412
+
1413
+
1414
+ def _seal_git(args, client: Client, root: Path, cfg: dict) -> int:
1415
+ """Incremental seals of one commit or a range (ADR 0001): only new blobs are sent."""
1416
+ key = _developer_key(required=True)
1417
+ try:
1418
+ commits = (gitrepo.commits(root, args.all_commits) if args.all_commits
1419
+ else [gitrepo.resolve(root, args.rev)])
1420
+ except gitrepo.GitError as exc:
1421
+ raise Abort(f"cannot read the repository: {exc}") from exc
1422
+ last = None
1423
+ for i, commit in enumerate(commits):
1424
+ try:
1425
+ tag = args.tag
1426
+ if args.kind == "release" and not tag:
1427
+ tag = next(iter(gitrepo.tags_at(root, commit)), None)
1428
+ out = chainseal.seal_commit(client, root, cfg["project_id"], commit, args.kind, key,
1429
+ cfg.get("exclude"), _confirm_upload(args, client),
1430
+ tag=tag, public_tag=args.public_tag)
1431
+ except (ApiError, NetworkError) as exc:
1432
+ raise SealFailed(f"sealing failed: {exc}") from exc
1433
+ except (gitrepo.GitError, RuntimeError) as exc:
1434
+ raise SealFailed(str(exc)) from exc
1435
+ if out.get("cancelled"):
1436
+ raise Abort("cancelled; nothing was sent")
1437
+ if "release" in out:
1438
+ _finish_release(args, client, root, out, key)
1439
+ continue
1440
+ if out.get("released"):
1441
+ _say(f"Already released: {out['released']} labels seal {out['already']} with this "
1442
+ f"tag (commit {commit[:12]}; nothing logged)")
1443
+ continue
1444
+ if "already" in out:
1445
+ _say(f"Already sealed: {out['already']} (commit {commit[:12]}: this tree is "
1446
+ "already in the project's chain; no new seal, nothing logged)")
1447
+ continue
1448
+ chainseal.record(root, out["result"], commit, out["info"]["ref"])
1449
+ if i < len(commits) - 1:
1450
+ _say(f"sealed {commit[:12]} as {out['result']['id']} "
1451
+ f"({out['uploaded']} file(s) uploaded)")
1452
+ last = out
1453
+ if last is None:
1454
+ return 0
1455
+ result = last["result"]
1456
+ keys, _a, checks = _accept(args, client, result)
1457
+ signer = trust.fingerprint(trust.public_raw(key))
1458
+ extra = [f"Commit {last['commit'][:12]}" + (f" ({last['info']['ref']})"
1459
+ if last["info"]["ref"] else ""),
1460
+ (f"Files {last['files']} Python file(s); uploaded {last['uploaded']} "
1461
+ f"({project.human_size(last['upload_bytes'])}), the rest were already "
1462
+ "fingerprinted")]
1463
+ rel = result["imprint"].get("release")
1464
+ if rel:
1465
+ extra.append(f"Tag {last['tag']} (public)" if "tag" in rel else
1466
+ f"Tag {last['tag']} (private: only a salted commitment is public; "
1467
+ "--public-tag publishes it)")
1468
+ return _finish(args, client, root, result, keys, checks, signer, extra)
1469
+
1470
+
1471
+ def _finish_release(args, client: Client, root: Path, out: dict, key) -> None:
1472
+ """Check a release record's receipt, save it, and print the summary."""
1473
+ result = out["release"]
1474
+ rid, rec, rel = result["id"], result["receipt"], result["release"]
1475
+ try:
1476
+ keys, problems = trust.trust_server_keys(client.url, client.published_keys())
1477
+ except (ApiError, NetworkError) as exc:
1478
+ raise SealFailed(f"cannot fetch the server's keys: {exc}") from exc
1479
+ if problems:
1480
+ raise SealFailed("the server's attestation keys changed unexpectedly ("
1481
+ + "; ".join(problems) + "); not saved")
1482
+ checks = receipt.verify(rec, keys, None, anchors=_anchors(args))
1483
+ bad = _failed(checks)
1484
+ if bad:
1485
+ raise SealFailed("the release record does not verify (" + "; ".join(
1486
+ f"{c.name}: {c.detail}" for c in bad) + "); not saved")
1487
+ folder = root / project.CONFIG_DIR
1488
+ folder.mkdir(parents=True, exist_ok=True)
1489
+ rec_path = folder / f"{rid}.receipt.json"
1490
+ rec_path.write_text(json.dumps(rec, indent=1, sort_keys=True) + "\n", encoding="utf-8")
1491
+ trust.save_project_keys(folder, client.url, keys)
1492
+ chainseal.record(root, result, out["commit"], out["info"]["ref"])
1493
+ for warning in rec.get("warnings") or []:
1494
+ _warn(warning)
1495
+ st, cp = rec["attestation"]["statement"], rec["log"]["checkpoint"]
1496
+ tag = out.get("tag")
1497
+ tag_line = ("none (pass --tag NAME)" if not tag else f"{tag} (public)" if "tag" in rel
1498
+ else f"{tag} (private: only a salted commitment is public; --public-tag "
1499
+ "publishes it)")
1500
+ _say(f"Release {rid}",
1501
+ f"Of seal {rel['seal']} (commit {out['commit'][:12]}: this tree was already sealed)",
1502
+ f"Tag {tag_line}",
1503
+ f"Sealed {st['attested_at']}, attested by {rec['attestation']['key_id']}",
1504
+ f"Log entry #{rec['log']['index']}, checkpoint of "
1505
+ f"{cp['statement']['tree_size']} entries",
1506
+ "Timestamps")
1507
+ for c in checks:
1508
+ if c.name.startswith("timestamp"):
1509
+ _say(f" {c.status:<5} {c.name.removeprefix('timestamp').strip() or '-':<10} "
1510
+ f"{c.detail}")
1511
+ signer = trust.fingerprint(trust.public_raw(key))
1512
+ _say(f"Signed developer key {signer}",
1513
+ f"Receipt {rec_path}",
1514
+ _verify_line(rid, config.hub_url(client.url, getattr(args, "hub_url", None)),
1515
+ rec_path), "", SEAL_NOTE)
1516
+
1517
+
1518
+ def _anchors(args) -> list:
1519
+ """Trusted TSA roots: the pinned ones, plus a PEM bundle the user chose (--tsa-certs)."""
1520
+ from . import timestamp
1521
+
1522
+ try:
1523
+ return timestamp.load_anchors(getattr(args, "tsa_certs", None))
1524
+ except (OSError, ValueError) as exc:
1525
+ raise Abort(f"cannot read --tsa-certs {args.tsa_certs}: {exc}") from exc
1526
+
1527
+
1528
+ def _step_summary(iid: str, rec: dict, stamps: list[receipt.Check], signer: str | None,
1529
+ hub: str | None = None) -> None:
1530
+ """In GitHub Actions, show the seal and its timestamp lines on the run's summary page."""
1531
+ path = os.environ.get("GITHUB_STEP_SUMMARY")
1532
+ if not path:
1533
+ return
1534
+ lines = [f"### CodeImprint seal `{iid}`", "",
1535
+ (f"- Log entry #{rec['log']['index']}, attested "
1536
+ f"{rec['attestation']['statement']['attested_at']}"),
1537
+ f"- Developer signature: {signer or 'none'}",
1538
+ f"- Verify: {verify_url(iid, hub) or 'imprint verify (no hub URL known)'}", "",
1539
+ "| Timestamp | Result | Detail |",
1540
+ "| --- | --- | --- |"]
1541
+ lines += [f"| {c.name.removeprefix('timestamp').strip() or '-'} | {c.status} | "
1542
+ f"{c.detail.replace('|', '/')} |" for c in stamps]
1543
+ with open(path, "a", encoding="utf-8") as f:
1544
+ f.write("\n".join(lines) + "\n\n")
1545
+
1546
+
1547
+ def cmd_seal(args) -> int:
1548
+ client = _client(args)
1549
+ root = Path(args.path)
1550
+ cfg = project.load_config(root) if root.is_dir() else {}
1551
+ try:
1552
+ if cfg.get("project_id") and not args.zip and gitrepo.is_repo(root):
1553
+ if not gitrepo.has_commits(root):
1554
+ raise Abort("this repository has no commits yet: commit first, then seal")
1555
+ rc = _seal_git(args, client, root, cfg)
1556
+ else:
1557
+ data = _prepare_upload(args.path, "seal", client, args.yes)
1558
+ rc = _seal_zip(args, client, data, cfg.get("project_id"), _folder_lines(args.path))
1559
+ except SealFailed as exc:
1560
+ if _ci(): # never break a build by surprise
1561
+ _warn(f"seal skipped — {exc}")
1562
+ return 0
1563
+ raise
1564
+ if rc == 0 and not getattr(args, "all_commits", None):
1565
+ try:
1566
+ key = _developer_key()
1567
+ except SealFailed:
1568
+ key = None
1569
+ if key is not None:
1570
+ _offer_identity(args, key, once=True) # the first seal on this server
1571
+ return rc
1572
+
1573
+
1574
+ # -- project names and the GitHub App (CIM-BE-19-v1) ---------------------------------------
1575
+ def cmd_project(args) -> int:
1576
+ key = _developer_key(required=True)
1577
+ project_id = _project_id(args)
1578
+ _rename(_client(args), project_id, key, args.name, not args.private)
1579
+ return 0
1580
+
1581
+
1582
+ GITHUB_MODES = {
1583
+ "upgrade": "The App made this project with a key CodeImprint holds. Your developer key "
1584
+ "becomes its key too, and authorises the App's key to keep sealing pushes: "
1585
+ "the project moves to your own key (non-custodial).",
1586
+ "attach": "This repository will be sealed into your existing project {project}: your key "
1587
+ "adds the App's key to it and authorises it to seal pushes. The project the App "
1588
+ "made ({replaces}) stays, with its history.",
1589
+ "authorise": "Your key authorises the App's key to seal pushes into this project.",
1590
+ }
1591
+
1592
+
1593
+ def cmd_github(args) -> int:
1594
+ key = _developer_key(required=True)
1595
+ root = Path(args.path)
1596
+ repository = args.repository
1597
+ if not repository and gitrepo.is_repo(root):
1598
+ host, name = gitrepo.repository_name(gitrepo.remote_url(root))
1599
+ repository = name if host == "github.com" else None
1600
+ if not repository:
1601
+ raise Abort("no GitHub repository: pass --repository owner/repo (the origin remote is "
1602
+ "not on github.com)")
1603
+ cfg = project.load_config(root) if root.is_dir() else {}
1604
+ project_id = args.project or cfg.get("project_id")
1605
+ pub = chainseal.public_key(key)
1606
+ sc = _session_client(args)
1607
+ try:
1608
+ prep = sc.github_link_prepare(repository, pub, project_id)
1609
+ except (ApiError, NetworkError) as exc:
1610
+ raise Abort(f"cannot link {repository}: {exc}") from exc
1611
+ fp = trust.fingerprint(trust.public_raw(key))
1612
+ if prep["mode"] == "authorised":
1613
+ _say(f"Repository {repository}", f"Project {prep['project_id']} (custody: "
1614
+ f"{prep.get('custody', 'owner')})", f"Already authorised by your key {fp} "
1615
+ f"({prep['authorisation']}): nothing to do.")
1616
+ return 0
1617
+ doc = prep["document"]
1618
+ if doc.get("authorised_by") != fp or doc.get("project") != prep["project_id"]:
1619
+ raise Abort("the server's authorisation names another key or project; not signing")
1620
+ _checked_digest(doc, prep["statement"]["digest"], prep["statement"])
1621
+ _say(f"Repository {repository}",
1622
+ f"Project {prep['project_id']}",
1623
+ f"App key {doc['key']} (held by CodeImprint; scope: {', '.join(doc['scope'])})",
1624
+ f"Your key {fp}", "",
1625
+ GITHUB_MODES[prep["mode"]].format(project=prep["project_id"],
1626
+ replaces=prep.get("replaces")), "",
1627
+ "You can withdraw it later by retiring the App's key from the project.")
1628
+ _ask(args, "Sign the authorisation (INT1) with your developer key?")
1629
+ try:
1630
+ done = sc.github_link_finalise(
1631
+ repository, pub, project_id, chainseal.sign(key, prep["statement"]),
1632
+ chainseal.sign(key, prep["key_statement"]) if prep.get("key_statement") else None)
1633
+ except (ApiError, NetworkError) as exc:
1634
+ raise Abort(f"linking failed: {exc}") from exc
1635
+ _checked(sc, done["receipt"], "authorisation record")
1636
+ folder = root / project.CONFIG_DIR
1637
+ if root.is_dir() and (folder.is_dir() or gitrepo.is_repo(root)):
1638
+ folder.mkdir(parents=True, exist_ok=True)
1639
+ (folder / f"{done['authorisation']}.receipt.json").write_text(
1640
+ json.dumps(done["receipt"], indent=1, sort_keys=True) + "\n", encoding="utf-8")
1641
+ if not cfg.get("project_id"):
1642
+ cfg_path = folder / project.CONFIG_FILE
1643
+ current = project.load_config(root)
1644
+ current["project_id"] = done["project_id"]
1645
+ current.setdefault("developer", fp)
1646
+ cfg_path.write_text(json.dumps(current, indent=2) + "\n", encoding="utf-8")
1647
+ _say(f"Saved project {done['project_id']} in .imprint/config.json")
1648
+ _say(f"Authorised {done['authorisation']} (log entry #{done['receipt']['log']['index']})",
1649
+ f"Custody {done['custody']}: your key {fp} controls the project's chain; the App "
1650
+ "seals each push with its own key, authorised by yours")
1651
+ return 0
1652
+
1653
+
1654
+ def _print_result(r: dict, fail_above: float) -> None:
1655
+ _say(f"Reference {r.get('reference')}",
1656
+ f"Suspect {r.get('suspect')}",
1657
+ "",
1658
+ f"Score {r['score']:.2f} ({r['band']}) reverse {r['reverse_score']:.2f}",
1659
+ f"Matched {len(r['matches'])} of {r['reference_units']} reference units "
1660
+ f"(coverage {r['coverage']:.0%})")
1661
+ if r["matches"]:
1662
+ _say("", "Top matching units:")
1663
+ for m in r["matches"][:10]:
1664
+ ref = m["reference"].get("label") or f"unit {m['reference']['uid']}"
1665
+ sus = m["suspect"].get("label") or f"unit {m['suspect']['uid']}"
1666
+ _say(f" {m['similarity']:.2f} {ref} <-> {sus}")
1667
+ _say("", f"Fails at {fail_above:.2f}", f"Note: {r.get('note') or DISCLAIMER}")
1668
+
1669
+
1670
+ def cmd_check(args) -> int:
1671
+ root = Path(args.path)
1672
+ cfg = project.load_config(root) if root.is_dir() else {}
1673
+ reference = args.reference or os.environ.get("CODEIMPRINT_REFERENCE") or cfg.get("reference")
1674
+ if not reference:
1675
+ raise Abort("no reference: pass --reference <Imprint ID>")
1676
+ if not imprint_file.is_valid_id(reference):
1677
+ raise Abort(f"not a valid Imprint ID: {reference}")
1678
+ fail_above = args.fail_above if args.fail_above is not None else float(
1679
+ cfg.get("fail_above", DEFAULT_FAIL_ABOVE))
1680
+ client = _client(args)
1681
+ data = _prepare_upload(args.path, "check", client, args.yes)
1682
+ try:
1683
+ result = client.check(data, imprint_file.clean_id(reference))
1684
+ except NetworkError as exc:
1685
+ # Never break a build by surprise: an unreachable server is a warning, not a failure.
1686
+ _warn(f"check skipped — {exc}")
1687
+ return 0
1688
+ except ApiError as exc:
1689
+ if exc.status in {401, 403, 429} or exc.status >= 500:
1690
+ _warn(f"check skipped — {exc}")
1691
+ return 0
1692
+ raise Abort(f"check failed: {exc}") from exc
1693
+ if args.output:
1694
+ Path(args.output).write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8")
1695
+ if args.format == "json":
1696
+ print(json.dumps(result, indent=2))
1697
+ else:
1698
+ _print_result(result, fail_above)
1699
+ return 1 if result["score"] >= fail_above else 0
1700
+
1701
+
1702
+ # -- verify --------------------------------------------------------------------------------
1703
+ def _project_root(path: Path) -> Path:
1704
+ folder = path.resolve().parent
1705
+ return folder.parent if folder.name == project.CONFIG_DIR else folder
1706
+
1707
+
1708
+ def _load_target(args) -> tuple[dict | None, dict | None, Path]:
1709
+ """Find the .imprint document and the receipt for a file or an Imprint ID."""
1710
+ target = Path(args.target)
1711
+ if target.is_file():
1712
+ try:
1713
+ data = imprint_file.load(target)
1714
+ except (OSError, ValueError) as exc:
1715
+ raise Abort(f"cannot read {target}: {exc}") from exc
1716
+ if isinstance(data, dict) and data.get("format") == imprint_file.FORMAT:
1717
+ rec_path = Path(args.receipt) if args.receipt else receipt_path_for(target)
1718
+ rec = imprint_file.load(rec_path) if rec_path.is_file() else None
1719
+ return data, rec, _project_root(target)
1720
+ if isinstance(data, dict) and "attestation" in data:
1721
+ doc_path = target.with_name(target.name.removesuffix(".receipt.json") + ".imprint")
1722
+ doc = imprint_file.load(doc_path) if doc_path.is_file() else None
1723
+ return doc, data, _project_root(target)
1724
+ raise Abort(f"{target} is neither an .imprint file nor a seal receipt")
1725
+ if not imprint_file.is_valid_id(args.target, imprint_file.ALL_PREFIXES):
1726
+ raise Abort(f"no such file, and not an Imprint ID: {args.target}")
1727
+ iid = imprint_file.clean_id(args.target)
1728
+ root = Path(args.project or ".")
1729
+ for rec_path in sorted((root / project.CONFIG_DIR).glob("*.receipt.json")):
1730
+ rec = imprint_file.load(rec_path)
1731
+ if rec.get("imprint_id") == iid:
1732
+ doc_path = rec_path.with_name(rec_path.name.removesuffix(".receipt.json")
1733
+ + ".imprint")
1734
+ return (imprint_file.load(doc_path) if doc_path.is_file() else None), rec, root
1735
+ if not args.online:
1736
+ raise Abort(f"no local receipt for {iid} in {root / project.CONFIG_DIR}: pass "
1737
+ "--online to fetch its public record")
1738
+ return None, None, root
1739
+
1740
+
1741
+ def _trusted_keys(args, root: Path, client: Client | None,
1742
+ checks: list[receipt.Check]) -> list[dict]:
1743
+ if args.keys:
1744
+ try:
1745
+ keys = trust.load_keys_file(args.keys)
1746
+ except (OSError, ValueError, KeyError) as exc:
1747
+ raise Abort(f"cannot read {args.keys}: {exc}") from exc
1748
+ checks.append(receipt.Check("keys", receipt.PASS, f"pinned from {args.keys}"))
1749
+ return keys
1750
+ if client is not None:
1751
+ try:
1752
+ keys, problems = trust.trust_server_keys(client.url, client.published_keys())
1753
+ except (ApiError, NetworkError) as exc:
1754
+ raise Abort(f"cannot fetch the server's keys: {exc}") from exc
1755
+ if problems:
1756
+ checks.append(receipt.Check("keys", receipt.FAIL, "the server's published keys "
1757
+ "do not extend the ones trusted before: "
1758
+ + "; ".join(problems)))
1759
+ return trust.load_trusted().get(client.url, [])
1760
+ checks.append(receipt.Check("keys", receipt.PASS, f"trusted for {client.url}, "
1761
+ "consistent with every earlier fetch"))
1762
+ return keys
1763
+ keys = trust.all_trusted_keys()
1764
+ if keys:
1765
+ checks.append(receipt.Check("keys", receipt.PASS,
1766
+ "cached from earlier contact with the server"))
1767
+ return keys
1768
+ project_keys = root / project.CONFIG_DIR / trust.PROJECT_KEYS
1769
+ if project_keys.is_file():
1770
+ checks.append(receipt.Check("keys", receipt.WARN, f"read from {project_keys}, which "
1771
+ "travels with the code: pin keys with --keys FILE or "
1772
+ "check --online for an independent check"))
1773
+ return trust.load_keys_file(project_keys)
1774
+ return []
1775
+
1776
+
1777
+ def _server_from_project(root: Path) -> str | None:
1778
+ path = root / project.CONFIG_DIR / trust.PROJECT_KEYS
1779
+ try:
1780
+ return json.loads(path.read_text(encoding="utf-8")).get("server")
1781
+ except (OSError, ValueError, AttributeError):
1782
+ return None
1783
+
1784
+
1785
+ def _file_only(doc: dict) -> list[receipt.Check]:
1786
+ status = imprint_file.verify_document(doc)
1787
+ sig = ("not signed" if not status["signed"] else
1788
+ f"OK (signer {status['signer']})" if status["signature_ok"] else "INVALID")
1789
+ return [receipt.Check("digest", receipt.PASS if status["digest_ok"] else receipt.FAIL,
1790
+ "OK" if status["digest_ok"] else "MISMATCH — the file was changed"),
1791
+ receipt.Check("file signature",
1792
+ receipt.FAIL if status["signature_ok"] is False else receipt.PASS,
1793
+ sig),
1794
+ receipt.Check("receipt", receipt.WARN, "none found: this file is not sealed in a "
1795
+ "transparency log (run `imprint seal`)")]
1796
+
1797
+
1798
+ def cmd_verify(args) -> int:
1799
+ if not args.target:
1800
+ default = Path(".imprint") / "baseline.imprint"
1801
+ if not default.is_file():
1802
+ args.verify_parser.error("the following arguments are required: target")
1803
+ args.target = str(default)
1804
+ doc, rec, root = _load_target(args)
1805
+ checks: list[receipt.Check] = []
1806
+ client = _client(args, _server_from_project(root)) if args.online else None
1807
+ if rec is None and client is not None:
1808
+ iid = imprint_file.clean_id(args.target) if doc is None else (
1809
+ imprint_file.verify_document(doc)["id"])
1810
+ try:
1811
+ rec = (client.public_record(iid)
1812
+ if iid.split("-", 1)[0] in imprint_file.RECORD_PREFIXES
1813
+ else client.public_imprint(iid))
1814
+ except ApiError as exc:
1815
+ if doc is None:
1816
+ raise Abort(f"no public record for {iid}: {exc}") from exc
1817
+ except NetworkError as exc:
1818
+ raise Abort(str(exc)) from exc
1819
+ if rec is None:
1820
+ checks = _file_only(doc)
1821
+ iid = imprint_file.verify_document(doc)["id"]
1822
+ else:
1823
+ iid = rec.get("imprint_id")
1824
+ keys = _trusted_keys(args, root, client, checks)
1825
+ developer = project.load_config(root).get("developer") if root.is_dir() else None
1826
+ anchors = _anchors(args)
1827
+ if args.tsa_certs:
1828
+ extra = len(anchors) - len(PINNED_ROOTS)
1829
+ checks.append(receipt.Check("tsa roots", receipt.WARN,
1830
+ f"also trusting {extra} certificate(s) from "
1831
+ f"{args.tsa_certs}, beyond the pinned roots"))
1832
+ checks += receipt.verify(rec, keys, doc, developer=args.developer or developer,
1833
+ anchors=anchors)
1834
+ if client is not None and keys:
1835
+ try:
1836
+ checks += receipt.verify_online(rec, client, keys, anchors)
1837
+ except (ApiError, NetworkError, KeyError) as exc:
1838
+ checks.append(receipt.Check("log consistency", receipt.FAIL,
1839
+ f"cannot check the live log: {exc}"))
1840
+ failed = [c for c in checks if c.status == receipt.FAIL]
1841
+ warned = [c for c in checks if c.status == receipt.WARN]
1842
+ valid = not failed and not (args.strict and warned)
1843
+ if args.json:
1844
+ print(json.dumps({"imprint_id": iid, "valid": valid,
1845
+ "checks": [c.as_dict() for c in checks]}, indent=2))
1846
+ else:
1847
+ _say(f"{'Record ' if rec and 'record' in rec else 'Imprint'} {iid}", "")
1848
+ for c in checks:
1849
+ _say(f"{c.status:<5} {c.name:<20} {c.detail}")
1850
+ verdict = ("VALID" if valid else
1851
+ f"NOT VALID ({len(failed)} check(s) failed)" if failed else
1852
+ f"NOT VALID with --strict ({len(warned)} warning(s))")
1853
+ _say("", f"Result {verdict}", "", SEAL_NOTE)
1854
+ return 0 if valid else 1
1855
+
1856
+
1857
+ # -- parser --------------------------------------------------------------------------------
1858
+ def build_parser() -> argparse.ArgumentParser:
1859
+ p = argparse.ArgumentParser(prog="imprint", description="CodeImprint by Mudrika — seal and "
1860
+ "check your code with a CodeImprint server.")
1861
+ p.add_argument("--version", action="version", version=f"codeimprint {__version__}")
1862
+ sub = p.add_subparsers(dest="command", required=True)
1863
+
1864
+ lg = sub.add_parser("login", help="sign in (browser or e-mail link), or save an API token")
1865
+ lg.add_argument("--url", help="server URL, for example https://imprint.example.com")
1866
+ lg.add_argument("--provider", choices=["github", "gitlab", "google", "microsoft", "email"],
1867
+ help="sign in with this provider (default: the first one the server offers)")
1868
+ lg.add_argument("--email", help="your address, for --provider email")
1869
+ lg.add_argument("--no-browser", action="store_true",
1870
+ help="print the sign-in link instead of opening the browser")
1871
+ lg.add_argument("--api-token", action="store_true",
1872
+ help="save an API token for seal and check instead of signing in")
1873
+ lg.add_argument("--token-stdin", action="store_true",
1874
+ help="read an API token from stdin (CI) instead of signing in")
1875
+ lg.add_argument("--cf-access", action="store_true",
1876
+ help="also save a Cloudflare Access service token for this server (asks for "
1877
+ "the Client ID and Secret)")
1878
+ lg.add_argument("--public-url", help=argparse.SUPPRESS) # retired: the server names the hub
1879
+ lg.set_defaults(func=cmd_login)
1880
+
1881
+ n = sub.add_parser("init", help="create your developer key, .imprint/config.json and a "
1882
+ "GitHub workflow")
1883
+ n.add_argument("path", nargs="?", default=".")
1884
+ n.add_argument("--no-ci", action="store_true", help="do not add a GitHub workflow")
1885
+ n.add_argument("--no-key", action="store_true", help="do not create a developer key")
1886
+ n.add_argument("--force", action="store_true", help="overwrite files that already exist")
1887
+ n.add_argument("--url", help="server URL for creating the project (else login or "
1888
+ "CODEIMPRINT_URL)")
1889
+ n.add_argument("--no-project", action="store_true",
1890
+ help="do not create the project and its genesis seal now")
1891
+ n.add_argument("--name", help="the project's display name (default: owner/repo from the "
1892
+ "origin remote, else the folder's name); public")
1893
+ n.set_defaults(func=cmd_init)
1894
+
1895
+ pj = sub.add_parser("project", help="the project's display name: rename")
1896
+ pj.add_argument("action", choices=["rename"])
1897
+ pj.add_argument("name", help="the new name (1-100 characters)")
1898
+ pj.add_argument("path", nargs="?", default=".")
1899
+ pj.add_argument("--private", action="store_true",
1900
+ help="show the name to you only, not on the public project page")
1901
+ pj.add_argument("--project", help="project ID (default: .imprint/config.json)")
1902
+ pj.add_argument("--url", help="server URL (overrides login and CODEIMPRINT_URL)")
1903
+ pj.set_defaults(func=cmd_project)
1904
+
1905
+ gh = sub.add_parser("github", help="the GitHub App: link this repository's project to your "
1906
+ "developer key")
1907
+ gh.add_argument("action", choices=["link"])
1908
+ gh.add_argument("path", nargs="?", default=".")
1909
+ gh.add_argument("--repository", help="owner/repo (default: the origin remote on github.com)")
1910
+ gh.add_argument("--project", help="your existing project to attach (default: "
1911
+ ".imprint/config.json, else the App's project)")
1912
+ gh.add_argument("--yes", action="store_true", help="sign without asking")
1913
+ gh.add_argument("--url", help="server URL (overrides login and CODEIMPRINT_URL)")
1914
+ gh.set_defaults(func=cmd_github)
1915
+
1916
+ s = sub.add_parser("seal", help="seal a project on the server; saves .imprint/baseline.imprint "
1917
+ "and its receipt")
1918
+ s.add_argument("path", nargs="?", default=".")
1919
+ s.add_argument("--name", help="project name to store in the Imprint (optional)")
1920
+ s.add_argument("--hub-url", help="hub for the verify link and badge (default: "
1921
+ "CODEIMPRINT_HUB_URL, else the hub the server names)")
1922
+ s.add_argument("-o", "--output", help="where to save the Imprint")
1923
+ s.add_argument("--url", help="server URL (overrides login and CODEIMPRINT_URL)")
1924
+ s.add_argument("--yes", action="store_true", help="do not ask before uploading (CI)")
1925
+ s.add_argument("--tsa-certs", metavar="FILE",
1926
+ help="also trust the time-stamping authorities in this PEM bundle")
1927
+ s.add_argument("--kind", choices=["commit", "push", "release"], default="commit",
1928
+ help="seal kind in a Git repository (default commit)")
1929
+ s.add_argument("--rev", default="HEAD", help="commit to seal (default HEAD)")
1930
+ s.add_argument("--all-commits", metavar="RANGE",
1931
+ help="seal every commit in a range, oldest first (for example v1.0..HEAD)")
1932
+ s.add_argument("--tag", help="release tag to record (default: the tag on the commit); "
1933
+ "kept private unless --public-tag")
1934
+ s.add_argument("--public-tag", action="store_true",
1935
+ help="publish the tag name in the release record")
1936
+ s.add_argument("--zip", action="store_true",
1937
+ help="send the folder as a zip even in a Git repository")
1938
+ s.set_defaults(func=cmd_seal)
1939
+ hk = sub.add_parser("hook", help="install or run the Git hook that seals each commit or push")
1940
+ hk.add_argument("action", choices=["install", "run"])
1941
+ hk.add_argument("path", nargs="?", default=".")
1942
+ hk.add_argument("--on", choices=["commit", "push"], default="commit")
1943
+ hk.set_defaults(func=cmd_hook)
1944
+
1945
+ ch = sub.add_parser("chain", help="show the project's chain (server, plus this clone's commits)")
1946
+ ch.add_argument("path", nargs="?", default=".")
1947
+ ch.add_argument("--project", help="project ID (default: .imprint/config.json)")
1948
+ ch.add_argument("--url", help="server URL (overrides login and CODEIMPRINT_URL)")
1949
+ ch.set_defaults(func=cmd_chain)
1950
+
1951
+ idn = sub.add_parser("identity", help="your developer key's verified identity: show, bind, "
1952
+ "revoke")
1953
+ idn.add_argument("action", choices=["show", "bind", "revoke"])
1954
+ idn.add_argument("record", nargs="?", help="revoke: the identity record (IDT1-...)")
1955
+ idn.add_argument("--level", choices=["email", "vcs"], default="email",
1956
+ help="bind: e-mail (a verified address) or vcs (GitHub/GitLab account)")
1957
+ idn.add_argument("--show-email-domain", action="store_true",
1958
+ help="bind: show your e-mail's domain publicly (never the address)")
1959
+ idn.add_argument("--reason", choices=["holder-request", "key-compromised", "withdrawn"],
1960
+ default="holder-request", help="revoke: why")
1961
+ idn.add_argument("--compromised-since", metavar="TIME",
1962
+ help="revoke --reason key-compromised: UTC time, e.g. 2026-11-01T12:00:00Z")
1963
+ idn.add_argument("--key", help="show: another key's fingerprint")
1964
+ idn.add_argument("--url", help="server URL (overrides login and CODEIMPRINT_URL)")
1965
+ idn.set_defaults(func=cmd_identity)
1966
+
1967
+ cl = sub.add_parser("claim", help="ownership claims: create, show, withdraw, add-key, "
1968
+ "retire-key")
1969
+ cl.add_argument("action", choices=["create", "show", "withdraw", "add-key", "retire-key"])
1970
+ cl.add_argument("claim", nargs="?", help="the claim (CLM1-...), except for create")
1971
+ cl.add_argument("--basis", choices=["original-author", "employer", "assignee", "licensee"],
1972
+ default="original-author", help="create: on what basis (licensee is a "
1973
+ "licence claim, not ownership)")
1974
+ cl.add_argument("--org", help="create: claim for this organisation (you hold its admin key)")
1975
+ cl.add_argument("--identity", help="create: your identity record (default: your key's "
1976
+ "valid one)")
1977
+ cl.add_argument("--effective-at", metavar="TIME", help="create: UTC time the claim starts "
1978
+ "(default now)")
1979
+ cl.add_argument("--share", help='create: a share such as "0.5" (default: whole)')
1980
+ cl.add_argument("--jurisdiction", help="create: ISO 3166 code, e.g. AU")
1981
+ cl.add_argument("--document", action="append", metavar="KIND=FILE",
1982
+ help="create: commit to a supporting document (only a salted hash is "
1983
+ "sent); KIND is employment-contract, assignment, licence, cla or other")
1984
+ cl.add_argument("--also-sign", action="append", metavar="KEYFILE",
1985
+ help="another key (PEM) that must sign: a listed key, or the new key for "
1986
+ "add-key")
1987
+ cl.add_argument("--key", help="retire-key: the key's fingerprint")
1988
+ cl.add_argument("--project", help="project ID (default: .imprint/config.json)")
1989
+ cl.add_argument("--path", default=".", help="the project folder (default: .)")
1990
+ cl.add_argument("--url", help="server URL (overrides login and CODEIMPRINT_URL)")
1991
+ cl.add_argument("--yes", action="store_true", help="do not ask before signing")
1992
+ cl.set_defaults(func=cmd_claim)
1993
+
1994
+ asg = sub.add_parser("assign", help="assign a contributor key's seals to a claim (both "
1995
+ "sides sign)")
1996
+ asg.add_argument("claim", nargs="?", help="the claim (CLM1-...) the seals are assigned to")
1997
+ asg.add_argument("--from", dest="start", metavar="TIME", help="start of the window (UTC)")
1998
+ asg.add_argument("--until", metavar="TIME", help="end of the window (default: none)")
1999
+ asg.add_argument("--from-key", metavar="FINGERPRINT",
2000
+ help="the contributor key (default: your developer key)")
2001
+ asg.add_argument("--also-sign", action="append", metavar="KEYFILE",
2002
+ help="the other side's key (PEM), if you hold it")
2003
+ asg.add_argument("--complete", metavar="FILE", help="add your signature to a file the "
2004
+ "other side wrote, and submit it")
2005
+ asg.add_argument("-o", "--output", help="where to write a half-signed assignment")
2006
+ asg.add_argument("--url", help="server URL (overrides login and CODEIMPRINT_URL)")
2007
+ asg.add_argument("--yes", action="store_true", help="do not ask before submitting")
2008
+ asg.set_defaults(func=cmd_assign)
2009
+
2010
+ own = sub.add_parser("ownership", help="show and verify a project's ownership claims and "
2011
+ "the seals they cover")
2012
+ own.add_argument("action", choices=["show"])
2013
+ own.add_argument("path", nargs="?", default=".")
2014
+ own.add_argument("--project", help="project ID (default: .imprint/config.json)")
2015
+ own.add_argument("--json", action="store_true")
2016
+ own.add_argument("--url", help="server URL (overrides login and CODEIMPRINT_URL)")
2017
+ own.set_defaults(func=cmd_ownership)
2018
+
2019
+ og = sub.add_parser("org", help="organisations: create, show, domain, verify, disclose, join, "
2020
+ "approve, end")
2021
+ og.add_argument("action", choices=["create", "show", "domain", "verify", "disclose", "join",
2022
+ "approve", "end"])
2023
+ og.add_argument("org", nargs="?", help="the organisation's ID (approve: the membership file)")
2024
+ og.add_argument("target", nargs="?",
2025
+ help="domain, verify, disclose: the domain; end: the membership (MBR1-...)")
2026
+ og.add_argument("--basis", choices=["admin-approval", "domain-email", "google-workspace",
2027
+ "microsoft-365"], default="admin-approval",
2028
+ help="join: how membership is proven (the others use your sign-in on the "
2029
+ "organisation's verified domain)")
2030
+ og.add_argument("--also-sign", action="append", metavar="KEYFILE",
2031
+ help="join: the admin key (PEM), if you hold it")
2032
+ og.add_argument("-o", "--output", help="join: where to write the membership for the admin")
2033
+ og.add_argument("--url", help="server URL (overrides login and CODEIMPRINT_URL)")
2034
+ og.add_argument("--yes", action="store_true", help="do not ask before signing")
2035
+ og.set_defaults(func=cmd_org)
2036
+
2037
+ ct = sub.add_parser("contributor", help="record a contributor key on a project (the "
2038
+ "contributor and a project key sign)")
2039
+ ct.add_argument("--role", help="the contributor's role, for example developer")
2040
+ ct.add_argument("--contributor-key", metavar="FINGERPRINT",
2041
+ help="the contributor's key (default: your developer key)")
2042
+ ct.add_argument("--agreement", choices=["cla", "dco", "employment", "contract",
2043
+ "assignment"], help="an agreement with the "
2044
+ "contributor")
2045
+ ct.add_argument("--assigns-to", metavar="CLAIM",
2046
+ help="the claim (CLM1-...) the agreement assigns rights to (not for dco)")
2047
+ ct.add_argument("--from", dest="start", metavar="TIME", help="the agreement's start (UTC)")
2048
+ ct.add_argument("--until", metavar="TIME", help="the agreement's end (default: none)")
2049
+ ct.add_argument("--agreement-document", metavar="FILE",
2050
+ help="commit to the signed agreement (only a salted hash is sent)")
2051
+ ct.add_argument("--share", help='a share such as "0.25" (optional)')
2052
+ ct.add_argument("--also-sign", action="append", metavar="KEYFILE",
2053
+ help="the other side's key (PEM), if you hold it")
2054
+ ct.add_argument("--complete", metavar="FILE", help="add your signature to a file the other "
2055
+ "side wrote, and submit it")
2056
+ ct.add_argument("-o", "--output", help="where to write a half-signed record")
2057
+ ct.add_argument("--project", help="project ID (default: .imprint/config.json)")
2058
+ ct.add_argument("--path", default=".", help="the project folder (default: .)")
2059
+ ct.add_argument("--url", help="server URL (overrides login and CODEIMPRINT_URL)")
2060
+ ct.add_argument("--yes", action="store_true", help="do not ask before submitting")
2061
+ ct.set_defaults(func=cmd_contributor)
2062
+
2063
+ ex = sub.add_parser("export", help="download the full Imprint of a Git seal (owner)")
2064
+ ex.add_argument("imprint_id")
2065
+ ex.add_argument("path", nargs="?", default=".")
2066
+ ex.add_argument("-o", "--output", help="where to save it (default <ID>.full.imprint)")
2067
+ ex.add_argument("--url", help="server URL (overrides login and CODEIMPRINT_URL)")
2068
+ ex.set_defaults(func=cmd_export)
2069
+
2070
+
2071
+ c = sub.add_parser("check", help="check a project against a reference Imprint")
2072
+ c.add_argument("path", nargs="?", default=".")
2073
+ c.add_argument("--reference", help="Imprint ID of the reference stored on the server")
2074
+ c.add_argument("--fail-above", type=float,
2075
+ help=f"exit 1 when the score reaches this value (default {DEFAULT_FAIL_ABOVE})")
2076
+ c.add_argument("--format", choices=["text", "json"], default="text")
2077
+ c.add_argument("-o", "--output", help="also write the JSON result to this file")
2078
+ c.add_argument("--url", help="server URL (overrides login and CODEIMPRINT_URL)")
2079
+ c.add_argument("--yes", action="store_true", help="do not ask before uploading (CI)")
2080
+ c.set_defaults(func=cmd_check)
2081
+
2082
+ v = sub.add_parser("verify", help="verify a seal offline: .imprint file, receipt or ID")
2083
+ v.add_argument("target", nargs="?",
2084
+ help=".imprint file, .receipt.json file or Imprint ID (default: "
2085
+ ".imprint/baseline.imprint in this folder)")
2086
+ v.add_argument("--receipt", help="the receipt (default: <name>.receipt.json next to it)")
2087
+ v.add_argument("--keys", metavar="FILE",
2088
+ help="trust only these server keys (a codeimprint-keys.json)")
2089
+ v.add_argument("--developer", metavar="FINGERPRINT",
2090
+ help="require a signature by this developer key")
2091
+ v.add_argument("--online", action="store_true",
2092
+ help="also check the server's live log (consistency, order, registry)")
2093
+ v.add_argument("--project", help="where to look for receipts when given an ID")
2094
+ v.add_argument("--url", help="server URL for --online")
2095
+ v.add_argument("--tsa-certs", metavar="FILE",
2096
+ help="also trust the time-stamping authorities in this PEM bundle")
2097
+ v.add_argument("--strict", action="store_true", help="treat warnings as failures")
2098
+ v.add_argument("--json", action="store_true")
2099
+ v.set_defaults(func=cmd_verify, verify_parser=v)
2100
+ return p
2101
+
2102
+
2103
+ TERMS_HINT = ("accept the CodeImprint terms of service and privacy notice first: open "
2104
+ "{account}, sign in and accept them, then run this again")
2105
+
2106
+
2107
+ def _terms_hint(server_url: str | None) -> str:
2108
+ hub = config.hub_url(server_url)
2109
+ return TERMS_HINT.format(account=f"{hub}/account" if hub else "your account page on the hub")
2110
+
2111
+
2112
+ def main(argv: list[str] | None = None) -> int:
2113
+ args = build_parser().parse_args(argv)
2114
+ try:
2115
+ return args.func(args)
2116
+ except (Abort, FileNotFoundError, ValueError) as exc:
2117
+ cause = exc.__cause__
2118
+ if isinstance(cause, ApiError) and cause.code == "terms_required":
2119
+ exc = _terms_hint(config.resolve(getattr(args, "url", None)).url)
2120
+ print(f"imprint: error: {exc}", file=sys.stderr)
2121
+ return 2
2122
+ except KeyboardInterrupt:
2123
+ print("imprint: cancelled", file=sys.stderr)
2124
+ return 130
2125
+
2126
+
2127
+ if __name__ == "__main__":
2128
+ sys.exit(main())