Commit 468ebcc4ef

468ebcc4ef57de0fa0a14d342ae8013001bc8ae6

parent: bdab697b73

Unsigned

cmc <hello@cleberg.net> · 2026-08-09 01:32 UTC

Pin CI actions to SHA, refactor _cmd_verify, tidy CLI/tests/shell

Layout: unified · split

.github/workflows/release.yml +2 −2
@@ -11,7 +11,7 @@ jobs:
11 steps: 11 steps:
12 - uses: actions/checkout@v5 12 - uses: actions/checkout@v5
13 - name: Install uv 13 - name: Install uv
14 uses: astral-sh/setup-uv@v6 14 uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6
15 - name: Build 15 - name: Build
16 run: uv build 16 run: uv build
17 - name: Check 17 - name: Check
@@ -33,4 +33,4 @@ jobs:
33 name: dist 33 name: dist
34 path: dist/ 34 path: dist/
35 - name: Publish to PyPI 35 - name: Publish to PyPI
36 uses: pypa/gh-action-pypi-publish@release/v1 36 uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1
evidence_seal/cli.py +34 −25
@@ -18,6 +18,8 @@ from .manifest import (
18# Exit codes: 0 = intact/valid, 1 = tamper/verification failure, 2 = usage error. 18# Exit codes: 0 = intact/valid, 1 = tamper/verification failure, 2 = usage error.
19OK, FAILED, USAGE = 0, 1, 2 19OK, FAILED, USAGE = 0, 1, 2
20 20
21_OUT_HELP = "write here instead of overwriting the manifest"
22
21 23
22def _default_manifest_path(directory: Path) -> Path: 24def _default_manifest_path(directory: Path) -> Path:
23 return directory.parent / f"{directory.name}.manifest.json" 25 return directory.parent / f"{directory.name}.manifest.json"
@@ -79,6 +81,28 @@ def _cmd_seal(args) -> int:
79 return OK 81 return OK
80 82
81 83
84def _print_drift(result) -> None:
85 for path in result.modified:
86 print(f" MODIFIED {path}")
87 for path in result.added:
88 print(f" ADDED {path}")
89 for path in result.removed:
90 print(f" REMOVED {path}")
91 if not result.id_ok:
92 print(" MANIFEST id does not re-derive — the manifest itself was altered")
93 if not result.root_ok:
94 print(" MANIFEST Merkle root does not match the file list")
95
96
97def _verify_timestamp_cli(manifest: dict, tsa_cert: str | None) -> bool:
98 from .timestamp import verify_timestamp
99
100 cert = Path(tsa_cert).read_bytes() if tsa_cert else None
101 ts_ok, ts_message = verify_timestamp(manifest, tsa_cert=cert)
102 print(f" timestamp {'OK' if ts_ok else 'FAIL'}: {ts_message}")
103 return ts_ok
104
105
82def _cmd_verify(args) -> int: 106def _cmd_verify(args) -> int:
83 directory = Path(args.directory) 107 directory = Path(args.directory)
84 manifest_path = Path(args.manifest) if args.manifest else _default_manifest_path(directory) 108 manifest_path = Path(args.manifest) if args.manifest else _default_manifest_path(directory)
@@ -95,16 +119,7 @@ def _cmd_verify(args) -> int:
95 exclude=_manifest_exclude(directory, manifest_path), 119 exclude=_manifest_exclude(directory, manifest_path),
96 ) 120 )
97 121
98 for path in result.modified: 122 _print_drift(result)
99 print(f" MODIFIED {path}")
100 for path in result.added:
101 print(f" ADDED {path}")
102 for path in result.removed:
103 print(f" REMOVED {path}")
104 if not result.id_ok:
105 print(" MANIFEST id does not re-derive — the manifest itself was altered")
106 if not result.root_ok:
107 print(" MANIFEST Merkle root does not match the file list")
108 123
109 status = OK 124 status = OK
110 if not result.intact: 125 if not result.intact:
@@ -115,14 +130,8 @@ def _cmd_verify(args) -> int:
115 status = FAILED 130 status = FAILED
116 131
117 # Verify an embedded timestamp when present. 132 # Verify an embedded timestamp when present.
118 if manifest.get("timestamp"): 133 if manifest.get("timestamp") and not _verify_timestamp_cli(manifest, args.tsa_cert):
119 from .timestamp import verify_timestamp 134 status = FAILED
120
121 cert = Path(args.tsa_cert).read_bytes() if args.tsa_cert else None
122 ts_ok, ts_message = verify_timestamp(manifest, tsa_cert=cert)
123 print(f" timestamp {'OK' if ts_ok else 'FAIL'}: {ts_message}")
124 if not ts_ok:
125 status = FAILED
126 135
127 if status == OK: 136 if status == OK:
128 print(f"intact — {result.checked} files match the seal") 137 print(f"intact — {result.checked} files match the seal")
@@ -175,7 +184,7 @@ def _cmd_sign(args) -> int:
175 try: 184 try:
176 manifest = load_manifest(args.manifest) 185 manifest = load_manifest(args.manifest)
177 signed = sign_manifest(manifest, args.key) 186 signed = sign_manifest(manifest, args.key)
178 except (RuntimeError, FileNotFoundError, ValueError, OSError) as exc: 187 except (RuntimeError, ValueError, OSError) as exc:
179 print(f"error: {exc}", file=sys.stderr) 188 print(f"error: {exc}", file=sys.stderr)
180 return USAGE 189 return USAGE
181 write_manifest(signed, args.out or args.manifest) 190 write_manifest(signed, args.out or args.manifest)
@@ -189,7 +198,7 @@ def _cmd_ts_request(args) -> int:
189 try: 198 try:
190 manifest = load_manifest(args.manifest) 199 manifest = load_manifest(args.manifest)
191 request = build_request(manifest["id"]) 200 request = build_request(manifest["id"])
192 except (RuntimeError, FileNotFoundError, ValueError, KeyError, OSError) as exc: 201 except (RuntimeError, ValueError, KeyError, OSError) as exc:
193 print(f"error: {exc}", file=sys.stderr) 202 print(f"error: {exc}", file=sys.stderr)
194 return USAGE 203 return USAGE
195 out = args.out or f"{args.manifest}.tsq" 204 out = args.out or f"{args.manifest}.tsq"
@@ -210,7 +219,7 @@ def _cmd_ts_apply(args) -> int:
210 try: 219 try:
211 manifest = load_manifest(args.manifest) 220 manifest = load_manifest(args.manifest)
212 stamped = apply_timestamp(manifest, load_der(args.token)) 221 stamped = apply_timestamp(manifest, load_der(args.token))
213 except (RuntimeError, FileNotFoundError, ValueError, OSError) as exc: 222 except (RuntimeError, ValueError, OSError) as exc:
214 print(f"error: {exc}", file=sys.stderr) 223 print(f"error: {exc}", file=sys.stderr)
215 return USAGE 224 return USAGE
216 write_manifest(stamped, args.out or args.manifest) 225 write_manifest(stamped, args.out or args.manifest)
@@ -228,7 +237,7 @@ def _cmd_ts_submit(args) -> int:
228 except ValueError as exc: 237 except ValueError as exc:
229 print(f"error: {exc}", file=sys.stderr) 238 print(f"error: {exc}", file=sys.stderr)
230 return FAILED 239 return FAILED
231 except (RuntimeError, FileNotFoundError, KeyError, OSError) as exc: 240 except (RuntimeError, KeyError, OSError) as exc:
232 print(f"error: {exc}", file=sys.stderr) 241 print(f"error: {exc}", file=sys.stderr)
233 return USAGE 242 return USAGE
234 write_manifest(stamped, args.out or args.manifest) 243 write_manifest(stamped, args.out or args.manifest)
@@ -288,7 +297,7 @@ def _build_parser() -> argparse.ArgumentParser:
288 p_sign = sub.add_parser("sign", help="sign an existing manifest") 297 p_sign = sub.add_parser("sign", help="sign an existing manifest")
289 p_sign.add_argument("manifest") 298 p_sign.add_argument("manifest")
290 p_sign.add_argument("--key", required=True, metavar="PRIVATE_KEY") 299 p_sign.add_argument("--key", required=True, metavar="PRIVATE_KEY")
291 p_sign.add_argument("--out", help="write here instead of overwriting the manifest") 300 p_sign.add_argument("--out", help=_OUT_HELP)
292 p_sign.set_defaults(func=_cmd_sign) 301 p_sign.set_defaults(func=_cmd_sign)
293 302
294 _add_timestamp_commands(sub) 303 _add_timestamp_commands(sub)
@@ -307,14 +316,14 @@ def _add_timestamp_commands(sub) -> None:
307 p_apply = ts.add_parser("apply", help="bind a TSA response/token into the manifest") 316 p_apply = ts.add_parser("apply", help="bind a TSA response/token into the manifest")
308 p_apply.add_argument("manifest") 317 p_apply.add_argument("manifest")
309 p_apply.add_argument("--token", required=True, metavar="TSR", help="TSA response or token (DER)") 318 p_apply.add_argument("--token", required=True, metavar="TSR", help="TSA response or token (DER)")
310 p_apply.add_argument("--out", help="write here instead of overwriting the manifest") 319 p_apply.add_argument("--out", help=_OUT_HELP)
311 p_apply.set_defaults(func=_cmd_ts_apply) 320 p_apply.set_defaults(func=_cmd_ts_apply)
312 321
313 p_submit = ts.add_parser("submit", help="request, POST to a TSA, and bind in one step") 322 p_submit = ts.add_parser("submit", help="request, POST to a TSA, and bind in one step")
314 p_submit.add_argument("manifest") 323 p_submit.add_argument("manifest")
315 p_submit.add_argument("--tsa", required=True, metavar="URL", help="RFC 3161 TSA endpoint") 324 p_submit.add_argument("--tsa", required=True, metavar="URL", help="RFC 3161 TSA endpoint")
316 p_submit.add_argument("--timeout", type=float, default=30.0, help="network timeout (seconds)") 325 p_submit.add_argument("--timeout", type=float, default=30.0, help="network timeout (seconds)")
317 p_submit.add_argument("--out", help="write here instead of overwriting the manifest") 326 p_submit.add_argument("--out", help=_OUT_HELP)
318 p_submit.set_defaults(func=_cmd_ts_submit) 327 p_submit.set_defaults(func=_cmd_ts_submit)
319 328
320 p_tsv = ts.add_parser("verify", help="verify the manifest's embedded timestamp") 329 p_tsv = ts.add_parser("verify", help="verify the manifest's embedded timestamp")
scripts/e2e.sh +2 −2
@@ -17,7 +17,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
17REPO="$(dirname "$SCRIPT_DIR")" 17REPO="$(dirname "$SCRIPT_DIR")"
18 18
19# Prefer the project venv; fall back to whatever is on PATH. 19# Prefer the project venv; fall back to whatever is on PATH.
20if [ -x "$REPO/.venv/bin/python" ]; then 20if [[ -x "$REPO/.venv/bin/python" ]]; then
21 PY="$REPO/.venv/bin/python" 21 PY="$REPO/.venv/bin/python"
22else 22else
23 PY="$(command -v python3 || command -v python)" 23 PY="$(command -v python3 || command -v python)"
@@ -37,7 +37,7 @@ assert_exit() {
37 printf '$ %s\n' "$*" 37 printf '$ %s\n' "$*"
38 eval "$*" >"$W/out" 2>&1; local rc=$? 38 eval "$*" >"$W/out" 2>&1; local rc=$?
39 sed 's/^/ /' "$W/out" 39 sed 's/^/ /' "$W/out"
40 if [ "$rc" = "$exp" ]; then 40 if [[ "$rc" == "$exp" ]]; then
41 printf ' \033[32m✓ PASS\033[0m — %s (exit %s)\n' "$label" "$rc"; pass=$((pass+1)) 41 printf ' \033[32m✓ PASS\033[0m — %s (exit %s)\n' "$label" "$rc"; pass=$((pass+1))
42 else 42 else
43 printf ' \033[31m✗ FAIL\033[0m — %s (exit %s, expected %s)\n' "$label" "$rc" "$exp"; fail=$((fail+1)) 43 printf ' \033[31m✗ FAIL\033[0m — %s (exit %s, expected %s)\n' "$label" "$rc" "$exp"; fail=$((fail+1))
tests/test_cli.py +2 −1
@@ -85,7 +85,8 @@ def test_timestamp_request_apply_verify(pkg, tmp_path):
85 85
86 req = tmp_path / "m.tsq" 86 req = tmp_path / "m.tsq"
87 assert main(["timestamp", "request", str(manifest), "--out", str(req)]) == OK 87 assert main(["timestamp", "request", str(manifest), "--out", str(req)]) == OK
88 assert req.exists() and req.stat().st_size > 0 88 assert req.exists()
89 assert req.stat().st_size > 0
89 90
90 manifest_id = json.loads(manifest.read_text())["id"] 91 manifest_id = json.loads(manifest.read_text())["id"]
91 tsr = tmp_path / "resp.tsr" 92 tsr = tmp_path / "resp.tsr"
tests/test_timestamp.py +4 −2
@@ -196,8 +196,9 @@ def test_apply_accepts_bare_token(manifest):
196 196
197def test_apply_refuses_token_for_other_id(manifest): 197def test_apply_refuses_token_for_other_id(manifest):
198 wrong = "0" * 64 198 wrong = "0" * 64
199 token = issue_token(wrong)
199 with pytest.raises(ValueError, match="does not timestamp this manifest"): 200 with pytest.raises(ValueError, match="does not timestamp this manifest"):
200 apply_timestamp(manifest, issue_token(wrong)) 201 apply_timestamp(manifest, token)
201 202
202 203
203def test_timestamp_does_not_change_manifest_id(manifest): 204def test_timestamp_does_not_change_manifest_id(manifest):
@@ -232,7 +233,8 @@ def test_full_signature_verifies(manifest):
232 token, cert_pem = issue_signed_token(manifest["id"]) 233 token, cert_pem = issue_signed_token(manifest["id"])
233 ok, message = verify_token_signature(token, cert_pem) 234 ok, message = verify_token_signature(token, cert_pem)
234 assert ok 235 assert ok
235 assert "valid" in message and "Test TSA" in message 236 assert "valid" in message
237 assert "Test TSA" in message
236 238
237 239
238def test_full_signature_via_verify_timestamp(manifest): 240def test_full_signature_via_verify_timestamp(manifest):