#!/usr/bin/env python3 """Verify an Outcome Receipt without trusting the host that served it. Standalone: Python 3 and the `cryptography` package, nothing from Avouch (Avouch was Sharecut; its receipts still say provider "sharecut"). python3 verify_receipt.py scr_12_0123456789abcdef01234567 # fetched from the issuer, https://avouch.co python3 verify_receipt.py https://avouch.co scr_12_0123456789abcdef01234567 # the same, naming the receipt's host python3 verify_receipt.py --issuer https:// python3 verify_receipt.py --file receipt.json --keys keys.json # fully offline: keys.json from the issuer you trust add --require-live to exit 2 for a sandbox receipt ("livemode": false): signed, but an example, never a real sale The issuer's keys always come from the ISSUER (default https://avouch.co, or --issuer ), never from the host that served the receipt: any host can serve its own key beside a receipt it made up, and that pair would check out. So a receipt host that is not the issuer is refused (NOT VERIFIED, exit 1) before anything is checked. The signed token must also be the token you asked for, so a genuine receipt for some other sale can't stand in for this one. Exit 0 and prints the signed facts when the signature checks out; exit 1 and says why when it doesn't. Spec: RECEIPT-SPEC.md. """ import base64 import json import sys import urllib.error import urllib.parse import urllib.request from cryptography.exceptions import InvalidSignature from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey ISSUER = "https://avouch.co" # Avouch's own origin: the one place its receipt keys are trusted from KEY_PATHS = ("/.well-known/avouch-receipts.json", "/.well-known/sharecut-receipts.json") # the second is Avouch's older name def b64d(s): return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4)) def origin(url): """'scheme://host[:port]' in lower case, the default port dropped; None for anything that isn't an http(s) address.""" try: u = urllib.parse.urlsplit(url.strip()) port = u.port except (ValueError, AttributeError): return None if u.scheme.lower() not in ("http", "https") or not u.hostname: return None host = u.hostname.lower().rstrip(".") if ":" in host: host = "[%s]" % host if port and port != {"http": 80, "https": 443}[u.scheme.lower()]: host += ":%d" % port return "%s://%s" % (u.scheme.lower(), host) def verify(receipt, keys, expect_token=None): """(True, facts) or (False, reason). Facts come only from the signed text. `keys` must come from the issuer you trust.""" for f in ("signed", "signature", "kid"): if not isinstance(receipt.get(f), str): return False, "missing %s" % f key = next((k for k in keys.get("keys", []) if k.get("kid") == receipt["kid"] and k.get("alg") == "Ed25519"), None) if not key: return False, "no published key with kid %s" % receipt["kid"] try: Ed25519PublicKey.from_public_bytes(b64d(key["public_key"])).verify(b64d(receipt["signature"]), receipt["signed"].encode()) except (InvalidSignature, ValueError): return False, "the signature does not match: this receipt proves nothing" facts = json.loads(receipt["signed"]) if facts.get("token") != receipt.get("token", facts.get("token")): return False, "the token outside the signature differs from the signed one" if expect_token is not None and facts.get("token") != expect_token: return False, "this is a signed receipt for a different token (%s), not for %s" % (facts.get("token"), expect_token) return True, facts def _get(url): with urllib.request.urlopen(urllib.request.Request(url, headers={"User-Agent": "outcome-receipt-verify"}), timeout=20) as r: return json.loads(r.read()) def issuer_keys(issuer): """The issuer's published keys, from the issuer itself.""" last = None for p in KEY_PATHS: try: k = _get(issuer + p) if isinstance(k, dict) and k.get("keys"): return k except (urllib.error.URLError, OSError, ValueError) as e: last = e raise OSError("could not fetch the keys from %s (%s)" % (issuer, last)) def _take(a, flag): """The value after `flag`, removing both from the list; None when the flag isn't there.""" if flag not in a: return None i = a.index(flag) if i + 1 >= len(a): sys.exit("%s needs a value\n\n%s" % (flag, __doc__)) v = a[i + 1] del a[i:i + 2] return v def main(a): a = list(a) need_live = "--require-live" in a a = [x for x in a if x != "--require-live"] file_, keys_file, issuer_arg = _take(a, "--file"), _take(a, "--keys"), _take(a, "--issuer") tok = None if file_: if not keys_file or a: sys.exit(__doc__) receipt = json.load(open(file_)) keys = json.load(open(keys_file)) else: issuer = origin(issuer_arg or ISSUER) if not issuer: print("NOT VERIFIED: --issuer must be an http(s) origin, like https://avouch.co") return 1 if len(a) == 1: host, tok = issuer, a[0] elif len(a) == 2: host, tok = origin(a[0]), a[1] else: sys.exit(__doc__) if host != issuer: print("NOT VERIFIED: the receipt host %s is not the issuer %s." % (host or a[0], issuer)) print(" A receipt checks out only against keys fetched from the issuer you trust, never from the host that served it,") print(" because any host can publish its own key beside a receipt it made up. Fetch the receipt from %s," % issuer) print(" or pass --issuer if you really do trust %s as the issuer." % (host or a[0])) return 1 try: receipt = _get("%s/v1/receipts/%s" % (issuer, urllib.parse.quote(tok, safe=""))) keys = issuer_keys(issuer) except (urllib.error.URLError, OSError, ValueError) as e: print("NOT VERIFIED: could not fetch the receipt and keys from %s (%s)" % (issuer, e)) return 1 print("Issuer: %s (keys fetched from there)" % issuer) if not isinstance(receipt, dict) or not isinstance(keys, dict): print("NOT VERIFIED: the receipt or the key set is not a JSON object") return 1 ok, out = verify(receipt, keys, expect_token=tok) if not ok: print("NOT VERIFIED:", out) return 1 print("VERIFIED. Signed facts:") for k in sorted(out): print(" %-22s %s" % (k, out[k])) live = out.get("livemode") if live is False: print("SANDBOX RECEIPT (livemode false): genuinely signed, but an example from a sandbox. It is not a real sale.") elif live is None: print("No livemode field: an older copy. Fetch the receipt again; the issuer signs a fresh copy on every fetch.") if out.get("merchant_charge") and live is not False: print("Merchant: find %s in your own Stripe or Shopify to complete the check." % out["merchant_charge"]) return 2 if need_live and live is not True else 0 if __name__ == "__main__": sys.exit(main(sys.argv[1:]))