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-0.5.0a11.dist-info/METADATA +92 -0
- codeimprint-0.5.0a11.dist-info/RECORD +23 -0
- codeimprint-0.5.0a11.dist-info/WHEEL +4 -0
- codeimprint-0.5.0a11.dist-info/entry_points.txt +2 -0
- codeimprint-0.5.0a11.dist-info/licenses/LICENSE +177 -0
- codeimprint-0.5.0a11.dist-info/licenses/NOTICE +11 -0
- codeimprint_cli/__init__.py +7 -0
- codeimprint_cli/__main__.py +5 -0
- codeimprint_cli/api.py +315 -0
- codeimprint_cli/chainseal.py +175 -0
- codeimprint_cli/cli.py +2128 -0
- codeimprint_cli/config.py +181 -0
- codeimprint_cli/gitrepo.py +148 -0
- codeimprint_cli/imprint_file.py +128 -0
- codeimprint_cli/linecount.py +340 -0
- codeimprint_cli/ownership.py +341 -0
- codeimprint_cli/project.py +129 -0
- codeimprint_cli/receipt.py +679 -0
- codeimprint_cli/records.py +59 -0
- codeimprint_cli/timestamp.py +187 -0
- codeimprint_cli/transparency.py +71 -0
- codeimprint_cli/trust.py +190 -0
- codeimprint_cli/tsa_roots.py +137 -0
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())
|