#!/usr/bin/env python3 # SPDX-License-Identifier: MIT # Copyright (c) 2026 GAIP # # Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated # documentation files (the "Software"), to deal in the Software without restriction, including without limitation the # rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright # notice and this permission notice shall be included in all copies or substantial portions of the Software. # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE # WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS # OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR # OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """Verify a GAIP receipt offline. Python 3.8+, standard library only (spec: gaip-receipt-v1). Usage: python3 gaip_receipt_verify.py receipt.json gaip-receipt-keys.json ``gaip-receipt-keys.json`` is GAIP's published key set (https://www.gaipagents.com/.well-known/gaip-receipt-keys.json), saved once; nothing here touches the network. Exit code 0 when the signature holds, 1 when it does not. A valid result means GAIP's published key signed that digest. It does not prove the recorded event is true, or anyone's identity, ownership, value or outcome; it is not a qualified electronic signature, seal or timestamp. """ from __future__ import annotations import base64 import hashlib import json import re import sys ALG = "Ed25519" OFFER_SEEN = "gaip.second_look.offer_sha256.v1" #: Fields left out when the OFFER_SEEN digest is rebuilt from the receipt itself. ``handle_hash`` and #: ``handle_hash_retention`` (10 Oct 2026): the keyed binding to the caller's continuity handle sits beside the signed #: body and is dropped after 90 days, so the receipt verifies before and after. OFFER_SEEN_UNSIGNED_FIELDS = ("receipt_id", "observation_id", "offer_sha256", "signature", "signature_status", "handle_hash", "handle_hash_retention") _HEX64 = re.compile(r"[0-9a-f]{64}") # ------------------------------------------------------------------ Ed25519 verify (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 _add(a, b): x1, y1, z1, t1 = a x2, y2, z2, t2 = b aa = (y1 - x1) * (y2 - x2) % _P bb = (y1 + x1) * (y2 + x2) % _P cc = 2 * t1 * t2 * _D % _P dd = 2 * z1 * z2 % _P e, f, g, h = bb - aa, dd - cc, dd + cc, bb + aa return (e * f % _P, g * h % _P, f * g % _P, e * h % _P) def _mul(s, point): q = (0, 1, 1, 0) while s > 0: if s & 1: q = _add(q, point) point = _add(point, point) s >>= 1 return q def _equal(a, b): x1, y1, z1, _ = a x2, y2, z2, _ = b return (x1 * z2 - x2 * z1) % _P == 0 and (y1 * z2 - y2 * z1) % _P == 0 def _recover_x(y, sign): if y >= _P: return None x2 = (y * y - 1) * pow(_D * y * y + 1, _P - 2, _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(raw: bytes): if len(raw) != 32: return None y = int.from_bytes(raw, "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) _G_Y = 4 * pow(5, _P - 2, _P) % _P _G = (_recover_x(_G_Y, 0), _G_Y, 1, _recover_x(_G_Y, 0) * _G_Y % _P) def ed25519_verify(public_key: bytes, message: bytes, signature: bytes) -> bool: 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))) # ------------------------------------------------------------------ GAIP receipts def b64url_decode(text: str) -> bytes: return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4)) def canonical_json(value) -> bytes: """The canonical form GAIP hashes: JSON, keys sorted, no spaces, non-ASCII escaped as \\uXXXX.""" return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("ascii") def key_id(public_key: bytes) -> str: return hashlib.sha256(public_key).hexdigest()[:16] def _normal_digest(digest) -> str | None: if not isinstance(digest, str): return None text = digest.lower() for prefix in ("sha256:", "0x"): if text.startswith(prefix): text = text[len(prefix):] return text if _HEX64.fullmatch(text) else None def verify(receipt: dict, keys_document: dict) -> dict: """{"valid": bool, "reason": str, "kid", "purpose", "key_status", "digest"} for one receipt. ``receipt`` is either a whole receipt carrying a ``signature`` object, or the signature object itself (``alg``, ``kid``, ``sig``, ``purpose``, ``signed_digest``).""" out = {"valid": False, "reason": None, "kid": None, "purpose": None, "key_status": None, "digest": None} if not isinstance(receipt, dict): out["reason"] = "NOT_A_RECEIPT" return out if isinstance(receipt.get("observation"), dict) and "signature" not in receipt: receipt = receipt["observation"] # the /v1/free/receipts/ answer wraps the receipt signature = receipt.get("signature") if isinstance(receipt.get("signature"), dict) else receipt if signature.get("alg") != ALG or not signature.get("sig") or not signature.get("kid"): out["reason"] = "UNSIGNED_OR_UNKNOWN_ALGORITHM" return out purpose = signature.get("purpose") out["kid"], out["purpose"] = signature.get("kid"), purpose digest = _normal_digest(signature.get("signed_digest")) if purpose == OFFER_SEEN and signature is not receipt: body = {k: v for k, v in receipt.items() if k not in OFFER_SEEN_UNSIGNED_FIELDS} rebuilt = hashlib.sha256(canonical_json(body)).hexdigest() if rebuilt != _normal_digest(receipt.get("offer_sha256")) or (digest and digest != rebuilt): out["reason"] = "RECEIPT_CHANGED_SINCE_SIGNING" return out digest = rebuilt if not isinstance(purpose, str) or digest is None: out["reason"] = "NO_SIGNED_DIGEST" return out out["digest"] = digest keys = keys_document.get("keys") if isinstance(keys_document, dict) else None key = next((k for k in keys or [] if isinstance(k, dict) and k.get("kid") == signature.get("kid")), None) if key is None: out["reason"] = "UNKNOWN_KEY" return out out["key_status"] = key.get("status") if key.get("retired_reason") == "COMPROMISED": out["reason"] = "KEY_COMPROMISED" return out try: public = b64url_decode(str(key.get("x") or "")) sig = b64url_decode(str(signature.get("sig"))) except (ValueError, TypeError): out["reason"] = "BAD_ENCODING" return out if key_id(public) != key.get("kid"): out["reason"] = "KEY_ID_MISMATCH" return out if not ed25519_verify(public, f"{purpose}:{digest}".encode("ascii"), sig): out["reason"] = "SIGNATURE_DOES_NOT_MATCH" return out out["valid"], out["reason"] = True, "SIGNATURE_VALID" return out def main(argv=None) -> int: args = list(sys.argv[1:] if argv is None else argv) if len(args) != 2: print(__doc__.strip().splitlines()[2].strip()) return 2 with open(args[0], encoding="utf-8") as fh: receipt = json.load(fh) with open(args[1], encoding="utf-8") as fh: keys = json.load(fh) result = verify(receipt, keys) print(json.dumps(result, indent=2)) return 0 if result["valid"] else 1 if __name__ == "__main__": sys.exit(main())