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
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Where the client keeps the server URL and token, and how settings are resolved.
|
|
2
|
+
|
|
3
|
+
Order: command-line flag, then environment (CODEIMPRINT_URL, CODEIMPRINT_TOKEN), then the
|
|
4
|
+
user config file written by `imprint login`.
|
|
5
|
+
|
|
6
|
+
A server behind **Cloudflare Access** (a development lock) also needs a service token: the
|
|
7
|
+
``CF-Access-Client-Id`` and ``CF-Access-Client-Secret`` headers. They come from
|
|
8
|
+
CODEIMPRINT_CF_ACCESS_CLIENT_ID / CODEIMPRINT_CF_ACCESS_CLIENT_SECRET, or from
|
|
9
|
+
`imprint login --cf-access`, and are sent **only** to that one server's host (never to
|
|
10
|
+
another, never over plain HTTP except to this machine).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from urllib.parse import urlparse
|
|
21
|
+
|
|
22
|
+
CONFIG_NAME = "config.json"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def config_dir() -> Path:
|
|
26
|
+
override = os.environ.get("CODEIMPRINT_CONFIG_DIR")
|
|
27
|
+
if override:
|
|
28
|
+
return Path(override)
|
|
29
|
+
if os.name == "nt":
|
|
30
|
+
base = Path(os.environ.get("APPDATA") or Path.home() / "AppData" / "Roaming")
|
|
31
|
+
elif sys.platform == "darwin":
|
|
32
|
+
base = Path.home() / "Library" / "Application Support"
|
|
33
|
+
else:
|
|
34
|
+
base = Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config")
|
|
35
|
+
return base / "codeimprint"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def config_path() -> Path:
|
|
39
|
+
return config_dir() / CONFIG_NAME
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def load_user_config() -> dict:
|
|
43
|
+
path = config_path()
|
|
44
|
+
if not path.is_file():
|
|
45
|
+
return {}
|
|
46
|
+
try:
|
|
47
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
48
|
+
except (OSError, ValueError):
|
|
49
|
+
return {}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _write(settings: dict) -> Path:
|
|
53
|
+
path = config_path()
|
|
54
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
data = json.dumps(settings, indent=2) + "\n"
|
|
56
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
57
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
58
|
+
f.write(data)
|
|
59
|
+
if os.name != "nt":
|
|
60
|
+
path.chmod(0o600)
|
|
61
|
+
return path
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def save_user_config(url: str, token: str | None) -> Path:
|
|
65
|
+
"""Write the config readable by the current user only (a sign-in session is kept)."""
|
|
66
|
+
keep = ("session", "cf_access", "hubs")
|
|
67
|
+
settings = {k: v for k, v in load_user_config().items() if k in keep}
|
|
68
|
+
settings.update(url=url, token=token or None)
|
|
69
|
+
return _write(settings)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def save_session(url: str, session: dict) -> Path:
|
|
73
|
+
"""Keep a sign-in session (from `imprint login`) for this server, readable by the user
|
|
74
|
+
only. The session token is short-lived; nothing else about the login is stored."""
|
|
75
|
+
settings = load_user_config()
|
|
76
|
+
settings.setdefault("url", url)
|
|
77
|
+
settings["session"] = {"url": url.rstrip("/"), "token": session["token"],
|
|
78
|
+
"expires_at": session["expires_at"],
|
|
79
|
+
"provider": session.get("provider"), "handle": session.get("handle")}
|
|
80
|
+
return _write(settings)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def load_session(url: str) -> dict | None:
|
|
84
|
+
"""The stored session for this server, unless it has expired."""
|
|
85
|
+
from datetime import datetime, timezone
|
|
86
|
+
|
|
87
|
+
s = load_user_config().get("session") or {}
|
|
88
|
+
if not s.get("token") or s.get("url") != url.rstrip("/"):
|
|
89
|
+
return None
|
|
90
|
+
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
91
|
+
return s if s.get("expires_at", "") > now else None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
LOOPBACK = {"localhost", "127.0.0.1", "::1"}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _host(url: str | None) -> str | None:
|
|
98
|
+
return urlparse(url).hostname if url else None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def cf_access_for(url: str) -> tuple[str, str] | None:
|
|
102
|
+
"""The Cloudflare Access service token to send to ``url``, or None.
|
|
103
|
+
|
|
104
|
+
From the environment, it is sent to the host of CODEIMPRINT_CF_ACCESS_HOST, else of
|
|
105
|
+
CODEIMPRINT_URL, else of the saved server URL; saved by `imprint login --cf-access`, to the
|
|
106
|
+
host it was saved for. Only over HTTPS (or plain HTTP to this machine)."""
|
|
107
|
+
parts = urlparse(url or "")
|
|
108
|
+
host = parts.hostname
|
|
109
|
+
if not host or not (parts.scheme == "https" or (parts.scheme == "http" and host in LOOPBACK)):
|
|
110
|
+
return None
|
|
111
|
+
stored = load_user_config()
|
|
112
|
+
env_id = os.environ.get("CODEIMPRINT_CF_ACCESS_CLIENT_ID", "").strip()
|
|
113
|
+
env_secret = os.environ.get("CODEIMPRINT_CF_ACCESS_CLIENT_SECRET", "").strip()
|
|
114
|
+
if env_id and env_secret:
|
|
115
|
+
target = (os.environ.get("CODEIMPRINT_CF_ACCESS_HOST", "").strip()
|
|
116
|
+
or _host(os.environ.get("CODEIMPRINT_URL")) or _host(stored.get("url")))
|
|
117
|
+
if target and target.lower() == host.lower():
|
|
118
|
+
return env_id, env_secret
|
|
119
|
+
saved = stored.get("cf_access") or {}
|
|
120
|
+
if (saved.get("host", "").lower() == host.lower() and saved.get("client_id")
|
|
121
|
+
and saved.get("client_secret")):
|
|
122
|
+
return saved["client_id"], saved["client_secret"]
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def save_cf_access(url: str, client_id: str, client_secret: str) -> Path:
|
|
127
|
+
"""Keep a Cloudflare Access service token for this server's host (user-only file)."""
|
|
128
|
+
host = _host(url)
|
|
129
|
+
if not host:
|
|
130
|
+
raise ValueError("not a server URL")
|
|
131
|
+
settings = load_user_config()
|
|
132
|
+
settings["cf_access"] = {"host": host, "client_id": client_id,
|
|
133
|
+
"client_secret": client_secret}
|
|
134
|
+
return _write(settings)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass
|
|
138
|
+
class Settings:
|
|
139
|
+
url: str | None
|
|
140
|
+
token: str | None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def hub_url(server_url: str | None, flag: str | None = None) -> str | None:
|
|
144
|
+
"""The hub that shows this server's seals (<hub>/i/<ID>): ``--hub-url``, else
|
|
145
|
+
CODEIMPRINT_HUB_URL, else the ``public_url`` the server itself reported (cached per server
|
|
146
|
+
URL by `imprint login` and by every seal), else None: then no link is printed."""
|
|
147
|
+
for value in (flag, os.environ.get("CODEIMPRINT_HUB_URL")):
|
|
148
|
+
if value and value.strip():
|
|
149
|
+
return value.strip().rstrip("/")
|
|
150
|
+
if server_url:
|
|
151
|
+
return (load_user_config().get("hubs") or {}).get(server_url.rstrip("/"))
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def save_hub(server_url: str, public_url) -> str | None:
|
|
156
|
+
"""Remember the hub a server reported. A local hub (localhost) is believed only from a
|
|
157
|
+
local server, and a remote one only over HTTPS; anything else is not kept."""
|
|
158
|
+
if not isinstance(public_url, str) or not public_url.strip():
|
|
159
|
+
return None
|
|
160
|
+
hub = public_url.strip().rstrip("/")
|
|
161
|
+
parts, server_host = urlparse(hub), _host(server_url)
|
|
162
|
+
if not parts.hostname or parts.scheme not in ("https", "http"):
|
|
163
|
+
return None
|
|
164
|
+
if parts.hostname in LOOPBACK and server_host not in LOOPBACK:
|
|
165
|
+
return None
|
|
166
|
+
if parts.scheme == "http" and parts.hostname not in LOOPBACK:
|
|
167
|
+
return None
|
|
168
|
+
settings = load_user_config()
|
|
169
|
+
hubs = settings.get("hubs") or {}
|
|
170
|
+
if hubs.get(server_url.rstrip("/")) != hub:
|
|
171
|
+
hubs[server_url.rstrip("/")] = hub
|
|
172
|
+
settings["hubs"] = hubs
|
|
173
|
+
_write(settings)
|
|
174
|
+
return hub
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def resolve(url_flag: str | None = None) -> Settings:
|
|
178
|
+
stored = load_user_config()
|
|
179
|
+
url = url_flag or os.environ.get("CODEIMPRINT_URL") or stored.get("url")
|
|
180
|
+
token = os.environ.get("CODEIMPRINT_TOKEN") or stored.get("token")
|
|
181
|
+
return Settings(url=url.rstrip("/") if url else None, token=token or None)
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Read a Git repository for incremental sealing: tree listings, blobs and commit links.
|
|
2
|
+
|
|
3
|
+
Everything comes from `git` itself (ls-tree, cat-file, rev-parse, rev-list): the client
|
|
4
|
+
computes no hashes of its own over code. Blob and tree IDs are passed to the server as
|
|
5
|
+
claims; it never publishes them.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import subprocess
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class GitError(Exception):
|
|
15
|
+
"""Not a Git repository, or a git command failed."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _git(root: Path, *args: str, input: bytes | None = None) -> bytes:
|
|
19
|
+
try:
|
|
20
|
+
done = subprocess.run(["git", "-C", str(root), *args], input=input, capture_output=True,
|
|
21
|
+
check=True)
|
|
22
|
+
except FileNotFoundError as exc:
|
|
23
|
+
raise GitError("git is not installed") from exc
|
|
24
|
+
except subprocess.CalledProcessError as exc:
|
|
25
|
+
raise GitError(exc.stderr.decode(errors="replace").strip() or "git failed") from exc
|
|
26
|
+
return done.stdout
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def is_repo(root: Path) -> bool:
|
|
30
|
+
try:
|
|
31
|
+
return _git(root, "rev-parse", "--is-inside-work-tree").strip() == b"true"
|
|
32
|
+
except GitError:
|
|
33
|
+
return False
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def toplevel(root: Path) -> Path:
|
|
37
|
+
return Path(_git(root, "rev-parse", "--show-toplevel").decode().strip())
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def git_dir(root: Path) -> Path:
|
|
41
|
+
path = Path(_git(root, "rev-parse", "--git-dir").decode().strip())
|
|
42
|
+
return path if path.is_absolute() else Path(root) / path
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def prefix(root: Path) -> str:
|
|
46
|
+
"""The folder's path inside the repository ('' at the top), with a trailing '/'."""
|
|
47
|
+
return _git(root, "rev-parse", "--show-prefix").decode().strip()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def resolve(root: Path, rev: str) -> str:
|
|
51
|
+
return _git(root, "rev-parse", "--verify", f"{rev}^{{commit}}").decode().strip()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def remote_url(root: Path, name: str = "origin") -> str | None:
|
|
55
|
+
try:
|
|
56
|
+
url = _git(root, "remote", "get-url", name).decode().strip()
|
|
57
|
+
except GitError:
|
|
58
|
+
return None
|
|
59
|
+
return url or None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def repository_name(url: str | None) -> tuple[str | None, str | None]:
|
|
63
|
+
"""(host, "owner/repo") from a remote URL: https://host/owner/repo(.git),
|
|
64
|
+
git@host:owner/repo(.git) or ssh://git@host/owner/repo. Credentials in the URL are
|
|
65
|
+
dropped; (None, None) when it cannot be read."""
|
|
66
|
+
if not url:
|
|
67
|
+
return None, None
|
|
68
|
+
from urllib.parse import urlsplit
|
|
69
|
+
|
|
70
|
+
if "://" in url:
|
|
71
|
+
parts = urlsplit(url)
|
|
72
|
+
host, path = (parts.hostname or ""), parts.path
|
|
73
|
+
elif ":" in url and "@" in url.split(":", 1)[0]:
|
|
74
|
+
host_part, path = url.split(":", 1)
|
|
75
|
+
host = host_part.rsplit("@", 1)[1]
|
|
76
|
+
else:
|
|
77
|
+
return None, None
|
|
78
|
+
path = path.strip("/").removesuffix(".git").strip("/")
|
|
79
|
+
bits = [b for b in path.split("/") if b]
|
|
80
|
+
if len(bits) < 2:
|
|
81
|
+
return host.lower() or None, bits[0] if bits else None
|
|
82
|
+
return host.lower() or None, "/".join(bits[-2:])
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def has_commits(root: Path) -> bool:
|
|
86
|
+
try:
|
|
87
|
+
resolve(root, "HEAD")
|
|
88
|
+
return True
|
|
89
|
+
except GitError:
|
|
90
|
+
return False
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def commit_info(root: Path, commit: str) -> dict:
|
|
94
|
+
tree = _git(root, "rev-parse", f"{commit}^{{tree}}").decode().strip()
|
|
95
|
+
line = _git(root, "rev-list", "--parents", "-n", "1", commit).decode().split()
|
|
96
|
+
ref = ""
|
|
97
|
+
try:
|
|
98
|
+
ref = _git(root, "symbolic-ref", "-q", "HEAD").decode().strip()
|
|
99
|
+
except GitError:
|
|
100
|
+
pass
|
|
101
|
+
return {"commit": commit, "tree": tree, "parents": line[1:], "ref": ref or None}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def tags_at(root: Path, commit: str) -> list[str]:
|
|
105
|
+
"""Tags pointing at a commit, sorted (``git tag --points-at``)."""
|
|
106
|
+
return sorted(_git(root, "tag", "--points-at", commit).decode().split())
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def commits(root: Path, revision_range: str) -> list[str]:
|
|
110
|
+
"""Commits in a range, oldest first (``git rev-list --reverse``)."""
|
|
111
|
+
return _git(root, "rev-list", "--reverse", revision_range).decode().split()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def ls_tree(root: Path, commit: str, under: str = "") -> list[dict]:
|
|
115
|
+
"""Regular files of a commit: [{path (relative to ``under``), blob, size}]."""
|
|
116
|
+
out = _git(root, "ls-tree", "-r", "-z", "--long", "--full-tree", commit)
|
|
117
|
+
files = []
|
|
118
|
+
for record in out.split(b"\0"):
|
|
119
|
+
if not record:
|
|
120
|
+
continue
|
|
121
|
+
meta, _, path = record.partition(b"\t")
|
|
122
|
+
mode, kind, blob, size = meta.decode().split()
|
|
123
|
+
name = path.decode("utf-8", errors="surrogateescape")
|
|
124
|
+
if kind != "blob" or mode == "120000" or not name.startswith(under):
|
|
125
|
+
continue # submodules, symlinks, other folders
|
|
126
|
+
files.append({"path": name[len(under):], "blob": blob, "size": int(size)})
|
|
127
|
+
return files
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def read_blobs(root: Path, blob_ids: list[str]):
|
|
131
|
+
"""Yield (blob ID, content) for each ID, via one `git cat-file --batch` process."""
|
|
132
|
+
if not blob_ids:
|
|
133
|
+
return
|
|
134
|
+
proc = subprocess.Popen(["git", "-C", str(root), "cat-file", "--batch"],
|
|
135
|
+
stdin=subprocess.PIPE, stdout=subprocess.PIPE)
|
|
136
|
+
try:
|
|
137
|
+
for blob in blob_ids:
|
|
138
|
+
proc.stdin.write(blob.encode() + b"\n")
|
|
139
|
+
proc.stdin.flush()
|
|
140
|
+
header = proc.stdout.readline().split()
|
|
141
|
+
if len(header) != 3:
|
|
142
|
+
raise GitError(f"blob {blob} is missing from the repository")
|
|
143
|
+
data = proc.stdout.read(int(header[2]))
|
|
144
|
+
proc.stdout.read(1) # the newline after each object
|
|
145
|
+
yield blob, data
|
|
146
|
+
finally:
|
|
147
|
+
proc.stdin.close()
|
|
148
|
+
proc.wait()
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Offline verification of .imprint files (docs/imprint-format.md).
|
|
2
|
+
|
|
3
|
+
Checks only the public file format: canonical JSON, SHA-256 digest, optional Ed25519
|
|
4
|
+
signature and the Imprint ID. No secret is involved.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
FORMAT = "imprint"
|
|
15
|
+
ID_PREFIX = "IMP1"
|
|
16
|
+
RELEASE_PREFIX = "REL1" # release records: same shape, own namespace
|
|
17
|
+
BODY_CHECKED = (ID_PREFIX, RELEASE_PREFIX) # frozen: check character over the body only
|
|
18
|
+
RECORD_PREFIXES = ("IDT1", "REV1", "MBR1", "END1", "CLM1", "XFR1", "ASG1", "DCL1", "CTB1",
|
|
19
|
+
"NTC1", "DSC1", "INT1") # ownership records: check over prefix + body
|
|
20
|
+
ALL_PREFIXES = BODY_CHECKED + RECORD_PREFIXES
|
|
21
|
+
ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" # Crockford Base32: no I, L, O, U
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def b64d(text: str) -> bytes:
|
|
25
|
+
return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def canonical(doc: dict) -> bytes:
|
|
29
|
+
"""Sorted keys, no whitespace, UTF-8 without escaping (RFC 8785 for this data)."""
|
|
30
|
+
return json.dumps(doc, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def digest_of(doc: dict) -> str:
|
|
34
|
+
body = {k: v for k, v in doc.items() if k != "integrity"}
|
|
35
|
+
return hashlib.sha256(canonical(body)).hexdigest()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _check_char(body: str) -> str:
|
|
39
|
+
return ALPHABET[hashlib.sha256(body.encode("ascii")).digest()[0] >> 3]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _check_for(prefix: str, body: str) -> str:
|
|
43
|
+
return _check_char(body) if prefix in BODY_CHECKED else _check_char(f"{prefix}-{body}")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def imprint_id(digest_hex: str, prefix: str = ID_PREFIX) -> str:
|
|
47
|
+
if prefix not in ALL_PREFIXES:
|
|
48
|
+
raise ValueError(f"unknown ID prefix {prefix!r}")
|
|
49
|
+
value = int(digest_hex[:20], 16) # first 80 bits
|
|
50
|
+
body = "".join(ALPHABET[(value >> (75 - 5 * i)) & 31] for i in range(16))
|
|
51
|
+
groups = "-".join(body[i:i + 4] for i in range(0, 16, 4))
|
|
52
|
+
return f"{prefix}-{groups}-{_check_for(prefix, body)}"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def record_id(prefix: str, digest_hex: str) -> str:
|
|
56
|
+
"""An ownership record's ID from the SHA-256 of its canonical document."""
|
|
57
|
+
if prefix not in RECORD_PREFIXES:
|
|
58
|
+
raise ValueError(f"not an ownership record prefix: {prefix!r}")
|
|
59
|
+
return imprint_id(digest_hex, prefix)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def release_id(digest_hex: str) -> str:
|
|
63
|
+
"""A release record's ID: REL1-..., from the SHA-256 of its release document."""
|
|
64
|
+
return imprint_id(digest_hex, RELEASE_PREFIX)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def clean_id(text: str) -> str:
|
|
68
|
+
parts = text.strip().upper().split("-")
|
|
69
|
+
if len(parts) == 6:
|
|
70
|
+
parts[1:6] = [p.replace("O", "0").replace("I", "1").replace("L", "1") for p in parts[1:6]]
|
|
71
|
+
return "-".join(parts)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def is_valid_id(text: str, prefixes: tuple[str, ...] = (ID_PREFIX,)) -> bool:
|
|
75
|
+
parts = clean_id(text).split("-")
|
|
76
|
+
if len(parts) != 6 or parts[0] not in prefixes or len(parts[5]) != 1:
|
|
77
|
+
return False
|
|
78
|
+
body = "".join(parts[1:5])
|
|
79
|
+
return (len(body) == 16 and all(c in ALPHABET for c in body)
|
|
80
|
+
and _check_for(parts[0], body) == parts[5])
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def verify_document(doc: dict) -> dict:
|
|
84
|
+
"""Return a status dict; ``valid`` is True only if every present check passes."""
|
|
85
|
+
if not isinstance(doc, dict) or doc.get("format") != FORMAT:
|
|
86
|
+
raise ValueError("not an Imprint document")
|
|
87
|
+
integrity = doc.get("integrity") or {}
|
|
88
|
+
stored = integrity.get("sha256")
|
|
89
|
+
recomputed = digest_of(doc)
|
|
90
|
+
status = {"id": imprint_id(stored) if stored else None, "digest_ok": stored == recomputed,
|
|
91
|
+
"signed": False, "signature_ok": None, "signer": None, "valid": False}
|
|
92
|
+
sig = integrity.get("signature")
|
|
93
|
+
if sig:
|
|
94
|
+
from cryptography.exceptions import InvalidSignature
|
|
95
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
96
|
+
|
|
97
|
+
status["signed"] = True
|
|
98
|
+
try:
|
|
99
|
+
if sig.get("alg") != "Ed25519":
|
|
100
|
+
raise ValueError(f"unsupported signature algorithm {sig.get('alg')!r}")
|
|
101
|
+
public = b64d(sig["public_key"])
|
|
102
|
+
Ed25519PublicKey.from_public_bytes(public).verify(b64d(sig["value"]),
|
|
103
|
+
bytes.fromhex(stored))
|
|
104
|
+
status["signature_ok"] = True
|
|
105
|
+
status["signer"] = hashlib.sha256(public).hexdigest()[:16]
|
|
106
|
+
except (InvalidSignature, ValueError, KeyError, TypeError):
|
|
107
|
+
status["signature_ok"] = False
|
|
108
|
+
status["valid"] = bool(stored) and status["digest_ok"] and status["signature_ok"] is not False
|
|
109
|
+
return status
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def load(path: str | Path) -> dict:
|
|
113
|
+
return json.loads(Path(path).read_text(encoding="utf-8"))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def save(doc: dict, path: str | Path) -> Path:
|
|
117
|
+
"""Write the document the way the server stores it (sorted keys, one-space indent)."""
|
|
118
|
+
out = Path(path)
|
|
119
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
120
|
+
out.write_text(json.dumps(doc, indent=1, sort_keys=True) + "\n", encoding="utf-8")
|
|
121
|
+
return out
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def badge(imprint_id_text: str, public_url: str) -> str:
|
|
125
|
+
"""README badge Markdown (rust, B23C0C), linking to the seal's page on the hub."""
|
|
126
|
+
shield = imprint_id_text.replace("-", "--")
|
|
127
|
+
return (f"[]"
|
|
128
|
+
f"({public_url.rstrip('/')}/i/{imprint_id_text})")
|