""" urbot_verify.py - check that a .urbot bot file is exactly what URBot published. Python 3.8+, standard library only. python verify-urbot.py civicus.urbot (the standalone copy from https://urbot.net/verify-urbot.py) python civicus.urbot verify (the copy inside the file) What it checks: 1. every file inside matches the sha256 listed in manifest.json, and nothing was added 2. signature.json is a valid Ed25519 signature over manifest.json 3. the signing key is URBot's: the key id pinned below, or the key published at urbot.net Run the STANDALONE copy when it matters. The copy inside a file is only as trustworthy as that file: a tampered file could carry a verifier that lies. The standalone copy comes from urbot.net and has URBot's key id built in. """ import base64 import hashlib import json import sys import urllib.request import zipfile PINNED_KEY_ID = "a0cfe9c2e639806d" KEY_URL = "https://urbot.net/.well-known/urbot-signing-key.json" # ---------------------------------------------------------------- Ed25519 verification (RFC 8032, section 5.1.7) _P = 2 ** 255 - 19 _L = 2 ** 252 + 27742317777372353535851937790883648493 _D = -121665 * pow(121666, _P - 2, _P) % _P _SQRT_M1 = pow(2, (_P - 1) // 4, _P) def _recover_x(y, sign): if y >= _P: return None x2 = (y * y - 1) * pow(_D * y * y + 1, _P - 2, _P) % _P if x2 == 0: return None if sign else 0 x = pow(x2, (_P + 3) // 8, _P) if (x * x - x2) % _P != 0: x = x * _SQRT_M1 % _P if (x * x - x2) % _P != 0: return None if (x & 1) != sign: x = _P - x return x def _decompress(s): if len(s) != 32: return None y = int.from_bytes(s, "little") sign = y >> 255 y &= (1 << 255) - 1 x = _recover_x(y, sign) return None if x is None else (x, y, 1, x * y % _P) def _add(p, q): a = (p[1] - p[0]) * (q[1] - q[0]) % _P b = (p[1] + p[0]) * (q[1] + q[0]) % _P c = 2 * p[3] * q[3] * _D % _P d = 2 * p[2] * q[2] % _P e, f, g, h = b - a, d - c, d + c, b + a return (e * f % _P, g * h % _P, f * g % _P, e * h % _P) def _mul(s, p): q = (0, 1, 1, 0) while s > 0: if s & 1: q = _add(q, p) p = _add(p, p) s >>= 1 return q def _equal(p, q): return (p[0] * q[2] - q[0] * p[2]) % _P == 0 and (p[1] * q[2] - q[1] * p[2]) % _P == 0 _GY = 4 * pow(5, _P - 2, _P) % _P _GX = _recover_x(_GY, 0) _G = (_GX, _GY, 1, _GX * _GY % _P) def ed25519_verify(public_key, message, signature): if len(public_key) != 32 or len(signature) != 64: return False a = _decompress(public_key) r = _decompress(signature[:32]) if a is None or r is None: return False s = int.from_bytes(signature[32:], "little") if s >= _L: return False h = int.from_bytes(hashlib.sha512(signature[:32] + public_key + message).digest(), "little") % _L return _equal(_mul(s, _G), _add(r, _mul(h, a))) # ---------------------------------------------------------------- the archive check def verify_archive(path, fetch=True): """Returns (ok, lines). ok is True only if every check passes.""" lines, ok = [], True z = zipfile.ZipFile(path) names = set(z.namelist()) if "manifest.json" not in names: return False, ["FAIL no manifest.json: this is not a .urbot file"] manifest_bytes = z.read("manifest.json") manifest = json.loads(manifest_bytes.decode("utf-8")) listed = manifest.get("files") or {} bad = [n for n, h in listed.items() if n not in names or hashlib.sha256(z.read(n)).hexdigest() != h] extra = sorted(names - set(listed) - {"manifest.json", "signature.json"}) if bad or extra or not listed: ok = False lines.append("FAIL contents: %d changed or missing %s, %d added %s" % (len(bad), bad[:5], len(extra), extra[:5])) else: lines.append("ok contents: all %d files match the manifest" % len(listed)) if "signature.json" not in names: return False, lines + ["FAIL unsigned: no signature.json"] sig = json.loads(z.read("signature.json").decode("utf-8")) pub = base64.b64decode(sig.get("public_key", "")) key_id = hashlib.sha256(pub).hexdigest()[:16] if ed25519_verify(pub, manifest_bytes, base64.b64decode(sig.get("signature", ""))): lines.append("ok signature: valid Ed25519 signature over manifest.json (key %s)" % key_id) else: ok = False lines.append("FAIL signature: does not verify") # Trusted = URBot's pinned key id, or the key currently published at urbot.net (lets URBot rotate keys). pinned = key_id == PINNED_KEY_ID published = None # None = not checked or unreachable if fetch: try: with urllib.request.urlopen(KEY_URL, timeout=10) as r: published = base64.b64decode(json.loads(r.read().decode("utf-8")).get("public_key", "")) == pub except Exception as e: lines.append(".. publisher: could not reach %s (%s)" % (KEY_URL, e)) if pinned or published: how = ("pinned key id and the key published at urbot.net" if pinned and published else "pinned key id" if pinned else "key published at urbot.net") lines.append("ok publisher: signed by URBot (%s)" % how) else: ok = False lines.append("FAIL publisher: key %s is not URBot's (expected %s)" % (key_id, PINNED_KEY_ID)) lines.append("%s %s (%s), edition %s" % ("VERIFIED" if ok else "NOT VERIFIED", manifest.get("name"), manifest.get("slug"), manifest.get("edition"))) return ok, lines if __name__ == "__main__": if len(sys.argv) < 2: sys.exit("usage: python verify-urbot.py [--offline]") good, out = verify_archive(sys.argv[1], fetch="--offline" not in sys.argv) print("\n".join(out)) sys.exit(0 if good else 1)