#!/usr/bin/env python3 """Sign and publish the release manifest that tells installed agents a new version exists. The manifest is signed with the same Ed25519 key as the definition packs, and for the same reason: whoever serves dl.houndav.com must not be able to invent a version and point our users at a binary of their choosing. An agent discards a manifest that does not verify, so a mistake here is a silent no-update rather than a bad update. Usage: tools/publish-release.py --version 0.2.0 --deb dist/hound_0.2.0_amd64.deb \ [--notes-url https://houndav.com/changelog] [--out /srv/houndav/dl] """ import argparse, hashlib, json, pathlib, subprocess, sys, datetime KEY = pathlib.Path.home() / "agents/hound/.secrets/defs-signing.key" KEY_ID = "hound-2026" # Must match release.rs::canonical exactly. Signing a re-serialisation of a # parsed struct is a classic way to verify one thing and act on another, so # both sides build these bytes from the same field order and separators. def canonical(r): return ( "hound-release-v1\n" f"version={r['version']}\n" f"notes_url={r['notes_url']}\n" f"deb_url={r['deb_url']}\n" f"deb_sha256={r['deb_sha256']}\n" f"published={r['published']}\n" ) def main(): ap = argparse.ArgumentParser() ap.add_argument("--version", required=True) ap.add_argument("--deb", required=True, type=pathlib.Path) ap.add_argument("--notes-url", default="https://houndav.com/#changelog") ap.add_argument("--out", type=pathlib.Path, default=pathlib.Path("/srv/houndav/dl")) ap.add_argument("--deb-url", default=None) a = ap.parse_args() if not a.deb.is_file(): sys.exit(f"no such package: {a.deb}") if not KEY.is_file(): sys.exit(f"no signing key at {KEY}") digest = hashlib.sha256(a.deb.read_bytes()).hexdigest() deb_url = a.deb_url or f"https://dl.houndav.com/deb/{a.deb.name}" if not deb_url.startswith("https://dl.houndav.com/"): # The agent refuses anything off our own host, so publishing one would # produce an update nobody can install. sys.exit(f"the agent will refuse {deb_url}: it must be on dl.houndav.com") release = { "version": a.version, "notes_url": a.notes_url, "deb_url": deb_url, "deb_sha256": digest, "published": datetime.date.today().isoformat(), } try: from nacl.signing import SigningKey except ImportError: sys.exit("pip install pynacl") seed = KEY.read_bytes() if len(seed) == 64: seed = seed[:32] sk = SigningKey(seed) sig = sk.sign(canonical(release).encode()).signature.hex() manifest = {"key_id": KEY_ID, "signature": sig, "release": release} a.out.mkdir(parents=True, exist_ok=True) dest = a.out / "latest.json" dest.write_text(json.dumps(manifest, indent=2) + "\n") print(f"wrote {dest}") print(f" version {a.version} sha256 {digest[:16]}…") print(f" public key {sk.verify_key.encode().hex()}") if __name__ == "__main__": main()