diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 69df456..f23f48c 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -28,6 +28,6 @@ jobs: with: python-version: "3.12" - name: Install mypy - run: pip install mypy cffi cryptography + run: pip install mypy cffi cryptography segno - name: Run mypy run: mypy cli.py core/ --ignore-missing-imports diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 12b2437..23baf7f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -21,7 +21,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install pytest cffi cryptography + pip install pytest cffi cryptography segno - name: Run tests run: python -m pytest tests/ -v --tb=short - name: Proof-honesty gate (no sorry/admit in verified library) diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index bef5e90..6e2e194 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -12,6 +12,6 @@ jobs: with: python-version: '3.14' - name: Install mypy - run: pip install mypy cryptography + run: pip install mypy cryptography segno - name: Type check run: mypy . --ignore-missing-imports diff --git a/CHANGELOG.md b/CHANGELOG.md index f1fc0e7..78268e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- Product finishing (2026-10-07): embeddable certificate badges — `core/badge.py` renders version-pinned, tamper-evident SVG badges live from registry data (`Certified vX.Y.Z`), plus Markdown/HTML embed snippets via `axiomcode badge `; QR code image rendering — `core/qr.py` (pure-Python `segno`) renders the certificate's QR payload as PNG/SVG via `axiomcode qr `; read-only registry HTTP surface — `services/registry_app.py`, a zero-dependency WSGI app exposing the transparency-log registry (active list, full log, per-serial machine-readable JSON, human cert pages, live badges, QR images), runnable via `axiomcode serve-registry`. Non-GET requests rejected with 405; no mutation over HTTP, ever. - Portfolio certification rollout (2026-10-05): new `CONTRIBUTING.md` with the standing PR-flow discipline (draft PR → checks green → owner merges; no direct pushes to `main`; CHANGELOG entry under Unreleased + semver bump per PR; releases tagged `vX.Y.Z`); new `NOTICE.md` attribution file; version bumped 0.1.0 → 0.1.1 (patch, chore). - Flags: publishes are CI-only (GitHub Pages from `docs/`, PyPI publish on release, GitHub release on `v*` tags) — the signed-deploy wrapper currently covers Cloudflare Workers only, so a signed path for these artifacts needs the owner's decision; license headers on existing source files are a follow-up (new files in this PR carry the proprietary header). diff --git a/cli.py b/cli.py index 1e51805..4c0f05b 100644 --- a/cli.py +++ b/cli.py @@ -1355,6 +1355,80 @@ def cmd_registry( sys.exit(1) +def cmd_badge(serial: str, fmt: str = "markdown", base_url: str = "", output: str = ""): + """Certificate badge: print the embed snippet or render the SVG.""" + print(BANNER) + from core.badge import badge_data, badge_snippet, render_badge_svg + + base = base_url or "https://registry.axiomcode.dev" + reg = _default_registry() + if fmt == "svg": + svg = render_badge_svg(badge_data(serial, reg, base)) + if output: + Path(output).write_text(svg, encoding="utf-8") + print(f"[+] Badge SVG written to {output}") + else: + print(svg) + else: + snip = badge_snippet(serial, base) + text = snip["html"] if fmt == "html" else snip["markdown"] + if output: + Path(output).write_text(text + "\n", encoding="utf-8") + print(f"[+] Badge snippet written to {output}") + else: + print(text) + + +def cmd_qr(serial: str, fmt: str = "png", base_url: str = "", output: str = ""): + """Render the certificate's QR code (PNG/SVG) to a file or stdout.""" + print(BANNER) + from core.qr import render_qr_svg, verification_url + + reg = _default_registry() + cert = reg.get(serial) + if cert is None: + print(f"[-] Unknown serial in registry: {serial}") + sys.exit(1) + base = base_url or "https://registry.axiomcode.dev" + payload = (cert.qr_payload or "").strip() or verification_url(serial, base) + if fmt == "svg": + svg = render_qr_svg(payload) + if output: + Path(output).write_text(svg, encoding="utf-8") + else: + print(svg) + else: + from core.qr import render_qr_png + + png = render_qr_png(payload) + if output: + Path(output).write_bytes(png) + else: + sys.stdout.buffer.write(png) + if output: + print(f"[+] QR code ({fmt.upper()}) written to {output}") + print(f" Encodes: {payload}") + + +def cmd_serve_registry(host: str, port: int, base_url: str = ""): + """Serve the public certificate registry over HTTP (read-only).""" + print(BANNER) + import sys as _sys + + _sys.path.insert(0, str(Path(__file__).parent / "services")) + from wsgiref.simple_server import make_server + + from registry_app import create_app + + base = base_url or f"http://{host}:{port}" + reg_dir = Path(__file__).parent / "registry" + app = create_app(str(reg_dir), base) + with make_server(host, port, app) as httpd: + print(f"[+] AxiomCode registry serving {reg_dir} at http://{host}:{port} (read-only)") + print(" Ctrl-C to stop.") + httpd.serve_forever() + + def cmd_key_create(name: str, passphrase: str = ""): """Create an Ed25519 signing key.""" print(BANNER) @@ -1421,6 +1495,9 @@ def main(): "cert", "scan", "registry", + "badge", + "qr", + "serve-registry", "key", "audit", "version", @@ -1491,6 +1568,23 @@ def main(): p_reg.add_argument("--output", default="", help="Output file (for export)") p_reg.add_argument("--all", action="store_true", help="Include expired/revoked (for list)") + p_badge = sub.add_parser("badge", help="Certificate badge: embed snippet or SVG") + p_badge.add_argument("serial", help="Certificate serial number") + p_badge.add_argument("--format", default="markdown", choices=["markdown", "html", "svg"], help="Output format") + p_badge.add_argument("--base-url", default="", help="Public base URL of the registry host") + p_badge.add_argument("--output", default="", help="Write to file instead of stdout") + + p_qr = sub.add_parser("qr", help="Render certificate QR code (PNG/SVG)") + p_qr.add_argument("serial", help="Certificate serial number") + p_qr.add_argument("--format", default="png", choices=["png", "svg"], help="Output format") + p_qr.add_argument("--base-url", default="", help="Public base URL (used when cert has no QR payload)") + p_qr.add_argument("--output", default="", help="Write to file instead of stdout") + + p_srv = sub.add_parser("serve-registry", help="Serve the public registry over HTTP (read-only)") + p_srv.add_argument("--host", default="127.0.0.1") + p_srv.add_argument("--port", type=int, default=8000) + p_srv.add_argument("--base-url", default="", help="Public base URL (defaults to http://host:port)") + p_kc = sub.add_parser("key", help="Key management") p_kc.add_argument("action", choices=["create", "list"]) p_kc.add_argument("name", nargs="?", default="") @@ -1568,6 +1662,12 @@ def main(): output=getattr(args, "output", ""), show_all=getattr(args, "all", False), ) + elif args.command == "badge": + cmd_badge(args.serial, args.format, args.base_url, args.output) + elif args.command == "qr": + cmd_qr(args.serial, args.format, args.base_url, args.output) + elif args.command == "serve-registry": + cmd_serve_registry(args.host, args.port, args.base_url) elif args.command == "key": if args.action == "create": cmd_key_create(args.name, args.passphrase) diff --git a/core/badge.py b/core/badge.py new file mode 100644 index 0000000..d277e4d --- /dev/null +++ b/core/badge.py @@ -0,0 +1,147 @@ +""" +AxiomCode certificate badges +============================= +Embeddable, version-pinned, tamper-evident badges a builder puts on their +README or site. + +Tamper-evidence model (read this before embedding): +- The badge image is RENDERED SERVER-SIDE from live registry data at + request time (`/badge/.svg`). The embed snippet points at the + issuer-hosted URL, so a badge can never be copy-pasted into a false + claim: whatever page embeds it always shows the certificate's CURRENT + registry status. A revoked certificate's badge turns red everywhere it + is embedded, automatically. +- The badge message is version-pinned ("Certified v1.2.3") — it can never + claim more than the certificate's own artifact version. +- The badge links to the public certificate page, where anyone (or any + agent) can check the signature, validity window, and status. +""" + +from __future__ import annotations + +import html as _html + +# Status -> (badge message suffix style, color) +_COLORS = { + "certified": "#2cbe4e", # bright green + "verified": "#44cc11", # green + "revoked": "#e05d44", # red + "expired": "#fe7d37", # orange + "invalid": "#e05d44", # red — signature does not verify + "unverified": "#dfb317", # yellow — published but never machine-checked + "pending": "#dfb317", # yellow — not yet valid + "unknown": "#9f9f9f", # grey — serial not in registry +} + +_TIER_LABELS = {"certified": "Certified", "verified": "Verified"} + + +def badge_data(serial: str, registry, base_url: str, now: float | None = None) -> dict: + """Resolve the live badge state for a certificate serial. + + Returns a dict with: serial, status, label, message, color, cert_url, + badge_url. Status is one of: certified | verified | revoked | expired | + invalid | unverified | pending | unknown. + """ + base = base_url.rstrip("/") + cert_url = f"{base}/cert/{serial}" + badge_url = f"{base}/badge/{serial}.svg" + unknown = { + "serial": serial, + "status": "unknown", + "label": "axiomcode", + "message": "Unknown", + "color": _COLORS["unknown"], + "cert_url": cert_url, + "badge_url": badge_url, + } + try: + cert = registry.get(serial) + except Exception: + return unknown + if cert is None: + return unknown + if not cert.signature or not cert.verify(): + return {**unknown, "status": "invalid", "message": "Invalid signature", "color": _COLORS["invalid"]} + if registry.is_revoked(serial): + return {**unknown, "status": "revoked", "message": "Revoked", "color": _COLORS["revoked"]} + if (cert.verification_status or "") != "verified": + return {**unknown, "status": "unverified", "message": "Unverified", "color": _COLORS["unverified"]} + validity = cert.validity_status(now) + if validity == "expired": + return {**unknown, "status": "expired", "message": "Expired", "color": _COLORS["expired"]} + if validity == "not-yet-valid": + return {**unknown, "status": "pending", "message": "Pending", "color": _COLORS["pending"]} + tier_label = _TIER_LABELS.get((cert.tier or "").lower(), "Certified") + version = (cert.artifact_version or "").strip() + message = f"{tier_label} v{version}" if version else tier_label + status = (cert.tier or "").lower() if (cert.tier or "").lower() in ("certified", "verified") else "certified" + return { + "serial": serial, + "status": status, + "label": "axiomcode", + "message": message, + "color": _COLORS.get(status, _COLORS["certified"]), + "cert_url": cert_url, + "badge_url": badge_url, + } + + +def _text_width(text: str) -> float: + # Approximate Verdana 11px advance width; good enough for badges. + return sum(7.2 if c.isupper() or c in "mwMW@%" else 5.6 for c in text) + 2 + + +def render_badge_svg(data: dict) -> str: + """Render a shields.io-style flat badge SVG from badge_data().""" + label = data.get("label", "axiomcode") + message = data.get("message", "") + color = data.get("color", _COLORS["unknown"]) + lw = _text_width(label) + 12 + mw = _text_width(message) + 12 + total = lw + mw + lx = lw / 2 + mx = lw + mw / 2 + label_e = _html.escape(label) + message_e = _html.escape(message) + return ( + f'' + f"{label_e}: {message_e}" + '' + '' + '' + f'' + '' + f'' + f'' + f'' + '' + f'{label_e}' + f'{label_e}' + f'{message_e}' + f'{message_e}' + "" + ) + + +def badge_snippet(serial: str, base_url: str) -> dict: + """Embed snippets for a certificate badge. + + Returns {"markdown": ..., "html": ...}. Both point the at the + issuer-hosted badge URL (rendered live from the registry) and link to + the public certificate page — so the badge stays honest wherever it + is embedded. + """ + base = base_url.rstrip("/") + img = f"{base}/badge/{serial}.svg" + link = f"{base}/cert/{serial}" + return { + "markdown": f"[![AxiomCode certificate]({img})]({link})", + "html": ( + f'' + f'AxiomCode certificate {_html.escape(serial)}' + "" + ), + } diff --git a/core/qr.py b/core/qr.py new file mode 100644 index 0000000..50dd677 --- /dev/null +++ b/core/qr.py @@ -0,0 +1,73 @@ +""" +AxiomCode QR code rendering +============================ +Renders the certificate's QR payload (the public certificate verification +URL) as real QR code images (PNG/SVG) embedded in certificate artifacts. + +Uses `segno` — pure Python, zero dependencies — so the QR path stays +dependency-light and portable. +""" + +from __future__ import annotations + +try: + import segno +except ImportError: # pragma: no cover - exercised when segno missing + segno = None # type: ignore[assignment] + + +def _require_segno() -> None: + if segno is None: + raise RuntimeError( + "QR rendering requires the 'segno' package (pip install segno). It is listed in requirements.txt." + ) + + +def verification_url(serial: str, base_url: str) -> str: + """Canonical public verification URL for a certificate serial. + + This is what the QR code encodes: the public certificate page showing + live status (active / expired / revoked). + """ + if not serial or not str(serial).strip(): + raise ValueError("Cannot build a verification URL without a serial number") + if not base_url or not str(base_url).strip(): + raise ValueError("Cannot build a verification URL without a base URL") + return f"{base_url.rstrip('/')}/cert/{serial}" + + +def render_qr_png(payload: str, scale: int = 8, border: int = 2) -> bytes: + """Render a QR code PNG for the given payload. Returns PNG bytes.""" + _require_segno() + if not payload or not str(payload).strip(): + raise ValueError("QR payload must be a non-empty string") + qr = segno.make(str(payload), error="m") + import io + + buf = io.BytesIO() + qr.save(buf, kind="png", scale=scale, border=border) + return buf.getvalue() + + +def render_qr_svg(payload: str, scale: int = 8, border: int = 2) -> str: + """Render a QR code SVG for the given payload. Returns SVG text.""" + _require_segno() + if not payload or not str(payload).strip(): + raise ValueError("QR payload must be a non-empty string") + qr = segno.make(str(payload), error="m") + import io + + buf = io.BytesIO() + qr.save(buf, kind="svg", scale=scale, border=border) + return buf.getvalue().decode("utf-8") + + +def qr_for_certificate(cert, base_url: str, scale: int = 8) -> bytes: + """Render the QR PNG for a certificate. + + Uses the certificate's own `qr_payload` when set (it is covered by the + Ed25519 signature); otherwise builds the canonical verification URL + from the serial and base URL. + """ + payload = (cert.qr_payload or "").strip() or verification_url(cert.serial, base_url) + return render_qr_png(payload, scale=scale) diff --git a/requirements.txt b/requirements.txt index 5e9164e..7d8932f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ flask>=3.0.0 pydantic>=2.0.0 cryptography>=41.0.0 +segno>=1.5.0 # pure-Python QR rendering (no dependencies) diff --git a/services/registry_app.py b/services/registry_app.py new file mode 100644 index 0000000..e339691 --- /dev/null +++ b/services/registry_app.py @@ -0,0 +1,305 @@ +""" +AxiomCode public registry — read-only HTTP surface +=================================================== +Minimal WSGI application exposing the public certificate registry. +Pure standard library: no dependencies, runs anywhere (wsgiref for local +dev, any WSGI server or serverless adapter in production). + +READ-ONLY by construction: there are no mutation endpoints at all. +Publication and revocation happen through the CLI (`axiomcode registry +publish|revoke`), never over HTTP. Non-GET requests get 405. + +Endpoints (all GET): + / human storefront (active certificates) + /health {"ok": true} + /api/registry JSON list, active only (default storefront view) + /api/registry?status=all JSON list, full transparency log incl. revoked/expired + /api/export machine-readable registry export (agents verify + signatures without parsing images) + /api/cert/ machine-readable single certificate + live status + /cert/ human certificate page (live status, QR, badge snippet) + /badge/.svg badge SVG rendered LIVE from registry data — + tamper-evident, always current + /qr/.png QR code PNG encoding the certificate page URL + +Configuration via environment: + AXC_REGISTRY_DIR path to the registry directory (default: ./registry) + AXC_BASE_URL public base URL used in QR payloads and badge snippets + (default: http://localhost:8000) +""" + +from __future__ import annotations + +import html as _html +import json +import os +import re +import time +from pathlib import Path +from urllib.parse import parse_qs +from wsgiref.simple_server import make_server + +from core.badge import badge_data, badge_snippet, render_badge_svg +from core.qr import render_qr_png, verification_url +from core.registry import CertificateRegistry + +_SERIAL_RE = re.compile(r"^[A-Za-z0-9_-]{1,128}$") + + +def _json_response(start_response, obj, status="200 OK"): + body = json.dumps(obj, indent=2, sort_keys=True).encode("utf-8") + start_response(status, [("Content-Type", "application/json; charset=utf-8"), ("Content-Length", str(len(body)))]) + return [body] + + +def _html_response(start_response, doc: str, status="200 OK"): + body = doc.encode("utf-8") + start_response(status, [("Content-Type", "text/html; charset=utf-8"), ("Content-Length", str(len(body)))]) + return [body] + + +def _svg_response(start_response, svg: str, status="200 OK"): + body = svg.encode("utf-8") + start_response( + status, + [ + ("Content-Type", "image/svg+xml"), + ("Content-Length", str(len(body))), + ("Cache-Control", "no-cache"), + ], + ) + return [body] + + +def _png_response(start_response, png: bytes, status="200 OK"): + start_response( + status, + [("Content-Type", "image/png"), ("Content-Length", str(len(png))), ("Cache-Control", "no-cache")], + ) + return [png] + + +def _not_found(start_response, what="not found"): + return _json_response(start_response, {"error": what}, status="404 Not Found") + + +def _bad_request(start_response, what): + return _json_response(start_response, {"error": what}, status="400 Bad Request") + + +def _cert_entry(cert, status: str) -> dict: + """Machine-readable certificate entry (agent-verifiable identity).""" + return { + "serial": cert.serial, + "status": status, + "product_name": cert.product_name or cert.algorithm_name, + "artifact_version": cert.artifact_version, + "tier": cert.tier, + "owner": cert.owner, + "repo_or_website": cert.repo_or_website, + "verification_status": cert.verification_status, + "issued_at": cert.issued_at, + "expires_at": cert.expires_at, + "artifact_hash": cert.proof_hash, + "spec_hash": cert.spec_hash, + "signature": cert.signature, + "verify_key": cert.verify_key, + "key_id": cert.key_id, + "verify_url": cert.qr_payload, + "issuer": { + "name": cert.issuer_name, + "website": cert.issuer_website, + "contact": cert.issuer_contact, + }, + } + + +def create_app(registry_dir: str | Path | None = None, base_url: str | None = None): + registry_dir = Path(registry_dir or os.environ.get("AXC_REGISTRY_DIR", "registry")) + base_url = (base_url or os.environ.get("AXC_BASE_URL", "http://localhost:8000")).rstrip("/") + reg = CertificateRegistry(registry_dir) + + def app(environ, start_response): + method = environ.get("REQUEST_METHOD", "GET").upper() + if method not in ("GET", "HEAD"): + # Read-only surface: no mutation over HTTP, ever. + start_response("405 Method Not Allowed", [("Content-Type", "text/plain")]) + return [b"Method not allowed: this registry is read-only"] + path = environ.get("PATH_INFO", "/") or "/" + query = parse_qs(environ.get("QUERY_STRING", "")) + head_only = method == "HEAD" + + def respond(chunks): + if head_only: + # Discard bodies for HEAD but keep headers/status. + out = [] + for c in chunks: + out.append(b"" if isinstance(c, (bytes, bytearray)) else "") + return out + return chunks + + # ── /health ────────────────────────────────────────────────── + if path == "/health": + return respond(_json_response(start_response, {"ok": True})) + + # ── /api/registry ──────────────────────────────────────────── + if path == "/api/registry": + status_filter = query.get("status", ["active"])[0] + if status_filter not in ("active", "all"): + return respond(_bad_request(start_response, "status must be 'active' or 'all'")) + return respond( + _json_response( + start_response, + { + "registry": "AxiomCode Certificate Registry", + "status_filter": status_filter, + "exported_at": time.time(), + "certificates": reg.list_certificates(status_filter=status_filter), + }, + ) + ) + + # ── /api/export ────────────────────────────────────────────── + if path == "/api/export": + body = reg.export_json().encode("utf-8") + start_response( + "200 OK", + [("Content-Type", "application/json; charset=utf-8"), ("Content-Length", str(len(body)))], + ) + return respond([body]) + + # ── /api/cert/ ─────────────────────────────────────── + m = re.fullmatch(r"/api/cert/([^/]+)", path) + if m: + serial = m.group(1) + if not _SERIAL_RE.match(serial): + return respond(_bad_request(start_response, "invalid serial")) + cert = reg.get(serial) + if cert is None: + return respond(_not_found(start_response, f"unknown serial {serial}")) + return respond(_json_response(start_response, _cert_entry(cert, reg.status(serial)))) + + # ── /badge/.svg ────────────────────────────────────── + m = re.fullmatch(r"/badge/([^/]+)\.svg", path) + if m: + serial = m.group(1) + if not _SERIAL_RE.match(serial): + return respond(_bad_request(start_response, "invalid serial")) + data = badge_data(serial, reg, base_url) + return respond(_svg_response(start_response, render_badge_svg(data))) + + # ── /qr/.png ───────────────────────────────────────── + m = re.fullmatch(r"/qr/([^/]+)\.png", path) + if m: + serial = m.group(1) + if not _SERIAL_RE.match(serial): + return respond(_bad_request(start_response, "invalid serial")) + cert = reg.get(serial) + if cert is None: + return respond(_not_found(start_response, f"unknown serial {serial}")) + payload = (cert.qr_payload or "").strip() or verification_url(serial, base_url) + try: + png = render_qr_png(payload) + except RuntimeError as e: + return respond(_json_response(start_response, {"error": str(e)}, status="503 Service Unavailable")) + return respond(_png_response(start_response, png)) + + # ── /cert/ (human page) ────────────────────────────── + m = re.fullmatch(r"/cert/([^/]+)", path) + if m: + serial = m.group(1) + if not _SERIAL_RE.match(serial): + return respond(_bad_request(start_response, "invalid serial")) + cert = reg.get(serial) + if cert is None: + return respond( + _html_response( + start_response, _page("Unknown certificate", "

Unknown serial.

"), "404 Not Found" + ) + ) + status = reg.status(serial) + sig_ok = bool(cert.signature) and cert.verify() + rows = [ + ("Serial", cert.serial), + ("Status", status.upper()), + ("Signature", "VALID" if sig_ok else "INVALID"), + ("Product", cert.product_name or cert.algorithm_name), + ("Artifact version", cert.artifact_version), + ("Tier", cert.tier), + ("Owner", cert.owner), + ("Repo / website", cert.repo_or_website), + ("Verification status", cert.verification_status), + ("Issued", _fmt_ts(cert.issued_at)), + ("Expires", _fmt_ts(cert.expires_at)), + ("Issuer", f"{cert.issuer_name} — {cert.issuer_website}"), + ] + body_rows = "".join(f"{_html.escape(k)}{_html.escape(str(v))}" for k, v in rows) + snip = badge_snippet(serial, base_url) + doc = ( + f"

Certificate {_html.escape(serial)}

" + f'

QR code

' + f"{body_rows}
" + "

Embed this badge

" + f"
{_html.escape(snip['markdown'])}
" + f"
{_html.escape(snip['html'])}
" + ) + return respond(_html_response(start_response, _page(f"Certificate {serial}", doc))) + + # ── / (storefront) ─────────────────────────────────────────── + if path == "/": + entries = reg.list_certificates(status_filter="active") + items = "".join( + f'
  • {_html.escape(e["serial"])} — ' + f"{_html.escape(e['product_name'] or '')} v{_html.escape(e['artifact_version'] or '?')} " + f"({_html.escape(e['tier'] or '')})
  • " + for e in entries + ) + doc = ( + "

    AxiomCode Public Certificate Registry

    " + f"

    {len(entries)} active certificate(s). " + 'Full transparency log · ' + 'Machine-readable export

    ' + f"
      {items or '
    • No active certificates.
    • '}
    " + ) + return respond(_html_response(start_response, _page("AxiomCode Certificate Registry", doc))) + + return respond(_not_found(start_response)) + + return app + + +def _fmt_ts(ts: float) -> str: + if not ts: + return "—" + return time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime(ts)) + + +def _page(title: str, body: str) -> str: + t = _html.escape(title) + return ( + "" + f"{t}" + "" + f"{body}" + ) + + +def main() -> None: + import argparse + + p = argparse.ArgumentParser(description="Serve the AxiomCode public certificate registry (read-only)") + p.add_argument("--host", default="127.0.0.1") + p.add_argument("--port", type=int, default=8000) + p.add_argument("--registry-dir", default=os.environ.get("AXC_REGISTRY_DIR", "registry")) + p.add_argument("--base-url", default=os.environ.get("AXC_BASE_URL", "http://localhost:8000")) + args = p.parse_args() + app = create_app(args.registry_dir, args.base_url) + with make_server(args.host, args.port, app) as httpd: + print(f"[+] AxiomCode registry serving {args.registry_dir} at http://{args.host}:{args.port} (read-only)") + httpd.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/tests/test_product_finishing.py b/tests/test_product_finishing.py new file mode 100644 index 0000000..4308b87 --- /dev/null +++ b/tests/test_product_finishing.py @@ -0,0 +1,306 @@ +"""Product-finishing behavior tests: badge embed, QR rendering, registry HTTP. + +Every test here asserts BEHAVIOR, not files: a revoked certificate's badge +must render revoked, a tampered certificate's badge must not claim +certified, QR output must be a real QR encoding the verification URL, and +the HTTP surface must be read-only. +""" + +import json +import threading +import time +import urllib.request +from pathlib import Path +from wsgiref.simple_server import make_server + +import pytest + + +def _signed_cert(**overrides): + from core.security import Ed25519KeyPair, ProofCertificate + + kp = Ed25519KeyPair.generate() + params = dict( + algorithm_name="binary_search", + spec_hash="abc123", + proof_hash="def456", + theorem="theorem binary_search_correct", + verification_status="verified", + tier="certified", + artifact_version="1.2.3", + product_name="BinarySearch", + owner="Jane Builder", + repo_or_website="https://github.com/jane/bs", + owner_contact="jane@example.com", + issued_at=time.time() - 100, + expires_at=time.time() + 86400, + ) + params.update(overrides) + cert = ProofCertificate(**params) + cert.serial = ProofCertificate.generate_serial(kp.key_id) + cert.key_id = kp.key_id + cert.sign(kp.private_key) + return cert, kp + + +@pytest.fixture() +def registry_dir(tmp_path): + from core.registry import CertificateRegistry + + reg = CertificateRegistry(tmp_path / "registry") + cert, _ = _signed_cert() + reg.publish(cert) + revoked_cert, _ = _signed_cert(product_name="OldWidget", artifact_version="0.9.0") + reg.publish(revoked_cert) + reg.revoke(revoked_cert.serial, "superseded by 1.0.0") + expired_cert, _ = _signed_cert( + product_name="AncientLib", + artifact_version="0.1.0", + expires_at=time.time() - 10, + ) + reg.publish(expired_cert) + return tmp_path / "registry", cert, revoked_cert, expired_cert + + +BASE = "https://registry.example.com" + + +class TestQR: + def test_png_is_real_png(self): + from core.qr import render_qr_png + + png = render_qr_png("https://registry.example.com/cert/AXC-1") + assert png[:8] == b"\x89PNG\r\n\x1a\n" + assert len(png) > 200 + + def test_svg_is_well_formed(self): + import xml.etree.ElementTree as ET + + from core.qr import render_qr_svg + + svg = render_qr_svg("https://registry.example.com/cert/AXC-1") + root = ET.fromstring(svg) + assert root.tag.endswith("svg") + + def test_empty_payload_refused(self): + from core.qr import render_qr_png, render_qr_svg + + with pytest.raises(ValueError): + render_qr_png("") + with pytest.raises(ValueError): + render_qr_svg(" ") + + def test_different_payloads_different_codes(self): + from core.qr import render_qr_png + + a = render_qr_png("https://registry.example.com/cert/AXC-1") + b = render_qr_png("https://registry.example.com/cert/AXC-2") + assert a != b + + def test_verification_url(self): + from core.qr import verification_url + + assert verification_url("AXC-1", "https://r.example.com/") == "https://r.example.com/cert/AXC-1" + with pytest.raises(ValueError): + verification_url("", "https://r.example.com") + with pytest.raises(ValueError): + verification_url("AXC-1", "") + + def test_qr_for_certificate_prefers_signed_payload(self): + from core.qr import qr_for_certificate + + cert, _ = _signed_cert(qr_payload="https://registry.example.com/cert/AXC-SPECIAL") + png = qr_for_certificate(cert, "https://other.example.com") + assert png[:8] == b"\x89PNG\r\n\x1a\n" + # The signed payload wins over the base URL — it is covered by the signature. + from core.qr import render_qr_png + + assert png == render_qr_png("https://registry.example.com/cert/AXC-SPECIAL") + + def test_qr_for_certificate_falls_back_to_serial_url(self): + from core.qr import qr_for_certificate, render_qr_png, verification_url + + cert, _ = _signed_cert() + png = qr_for_certificate(cert, BASE) + assert png == render_qr_png(verification_url(cert.serial, BASE)) + + +class TestBadge: + def test_active_certified_badge_is_version_pinned(self, registry_dir): + from core.badge import badge_data, render_badge_svg + + reg_dir, cert, _, _ = registry_dir + from core.registry import CertificateRegistry + + data = badge_data(cert.serial, CertificateRegistry(reg_dir), BASE) + assert data["status"] == "certified" + assert data["message"] == "Certified v1.2.3" + assert "1.2.3" in data["message"] # never a bare "Certified" + svg = render_badge_svg(data) + assert "Certified v1.2.3" in svg + assert svg.startswith("