Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/typecheck.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <serial>`; QR code image rendering — `core/qr.py` (pure-Python `segno`) renders the certificate's QR payload as PNG/SVG via `axiomcode qr <serial>`; 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).

Expand Down
100 changes: 100 additions & 0 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -1421,6 +1495,9 @@ def main():
"cert",
"scan",
"registry",
"badge",
"qr",
"serve-registry",
"key",
"audit",
"version",
Expand Down Expand Up @@ -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="")
Expand Down Expand Up @@ -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)
Expand Down
147 changes: 147 additions & 0 deletions core/badge.py
Original file line number Diff line number Diff line change
@@ -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/<serial>.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'<svg xmlns="http://www.w3.org/2000/svg" width="{total:.0f}" height="20" role="img" '
f'aria-label="{label_e}: {message_e}">'
f"<title>{label_e}: {message_e}</title>"
'<linearGradient id="s" x2="0" y2="100%">'
'<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>'
'<stop offset="1" stop-opacity=".1"/></linearGradient>'
f'<clipPath id="r"><rect width="{total:.0f}" height="20" rx="3" fill="#fff"/></clipPath>'
'<g clip-path="url(#r)">'
f'<rect width="{lw:.0f}" height="20" fill="#555"/>'
f'<rect x="{lw:.0f}" width="{mw:.0f}" height="20" fill="{color}"/>'
f'<rect width="{total:.0f}" height="20" fill="url(#s)"/></g>'
'<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" '
'text-rendering="geometricPrecision" font-size="11">'
f'<text x="{lx:.0f}" y="15" fill="#010101" fill-opacity=".3">{label_e}</text>'
f'<text x="{lx:.0f}" y="14">{label_e}</text>'
f'<text x="{mx:.0f}" y="15" fill="#010101" fill-opacity=".3">{message_e}</text>'
f'<text x="{mx:.0f}" y="14">{message_e}</text>'
"</g></svg>"
)


def badge_snippet(serial: str, base_url: str) -> dict:
"""Embed snippets for a certificate badge.

Returns {"markdown": ..., "html": ...}. Both point the <img> 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'<a href="{_html.escape(link)}">'
f'<img src="{_html.escape(img)}" alt="AxiomCode certificate {_html.escape(serial)}" />'
"</a>"
),
}
73 changes: 73 additions & 0 deletions core/qr.py
Original file line number Diff line number Diff line change
@@ -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)
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading