169 lines
6.0 KiB
Python
169 lines
6.0 KiB
Python
|
|
#!/usr/bin/env python3
|
||
|
|
"""verify_receipts.py -- offline (L1) verification of the SZL-Khipu signed receipts.
|
||
|
|
|
||
|
|
Self-contained: given owner_pubkey.json + the two *.signed.json receipts, it
|
||
|
|
re-derives the keyId, verifies each Ed25519 signature over the receipt's
|
||
|
|
canonical JSON, confirms the embedded canonical string matches an independently
|
||
|
|
recomputed canonicalization of the payload, and recomputes the
|
||
|
|
evaluation->training hash chain. No network, no trust in the repo card.
|
||
|
|
|
||
|
|
keyId semantics (identical to szl-forge khipu/sign_receipt.py):
|
||
|
|
keyId = first 16 hex chars of sha256(SPKI DER of the public key)
|
||
|
|
|
||
|
|
Canonicalization (must match the TypeScript canonicalJson() in the server and
|
||
|
|
the Python canonical_json() in szl-forge): recursively sorted object keys,
|
||
|
|
compact separators, floats REFUSED (doctrine: strings + ints only).
|
||
|
|
|
||
|
|
Usage:
|
||
|
|
python3 verify_receipts.py \
|
||
|
|
--pubkey owner_pubkey.json \
|
||
|
|
--training training_receipt.signed.json \
|
||
|
|
--eval eval_receipt.signed.json
|
||
|
|
|
||
|
|
Exit code 0 and a final "PASS" line iff every check passes; non-zero + "FAIL"
|
||
|
|
otherwise. Requires the `cryptography` package.
|
||
|
|
"""
|
||
|
|
import argparse
|
||
|
|
import base64
|
||
|
|
import hashlib
|
||
|
|
import json
|
||
|
|
import sys
|
||
|
|
|
||
|
|
from cryptography.hazmat.primitives import serialization
|
||
|
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
||
|
|
from cryptography.exceptions import InvalidSignature
|
||
|
|
|
||
|
|
|
||
|
|
def canonical_json(value):
|
||
|
|
"""Byte-for-byte canonical JSON: sorted keys, compact separators, no floats."""
|
||
|
|
if isinstance(value, bool) or value is None or isinstance(value, (str, int)):
|
||
|
|
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
||
|
|
if isinstance(value, float):
|
||
|
|
raise TypeError(
|
||
|
|
"canonical_json refuses float values (doctrine: strings+ints only)"
|
||
|
|
)
|
||
|
|
if isinstance(value, list):
|
||
|
|
return "[" + ",".join(canonical_json(v) for v in value) + "]"
|
||
|
|
if isinstance(value, dict):
|
||
|
|
items = []
|
||
|
|
for k in sorted(value.keys()):
|
||
|
|
items.append(json.dumps(k, ensure_ascii=False) + ":" + canonical_json(value[k]))
|
||
|
|
return "{" + ",".join(items) + "}"
|
||
|
|
raise TypeError(f"canonical_json: unsupported type {type(value)!r}")
|
||
|
|
|
||
|
|
|
||
|
|
def sha256_hex(data) -> str:
|
||
|
|
if isinstance(data, str):
|
||
|
|
data = data.encode("utf-8")
|
||
|
|
return hashlib.sha256(data).hexdigest()
|
||
|
|
|
||
|
|
|
||
|
|
def spki_der_from_b64(b64: str) -> bytes:
|
||
|
|
der = base64.b64decode(b64)
|
||
|
|
# Round-trip through cryptography to confirm it is a real Ed25519 SPKI.
|
||
|
|
pub = serialization.load_der_public_key(der)
|
||
|
|
if not isinstance(pub, Ed25519PublicKey):
|
||
|
|
raise TypeError("public key is not Ed25519")
|
||
|
|
return der
|
||
|
|
|
||
|
|
|
||
|
|
def key_id_from_spki(spki_der: bytes) -> str:
|
||
|
|
return sha256_hex(spki_der)[:16]
|
||
|
|
|
||
|
|
|
||
|
|
def load(path):
|
||
|
|
with open(path, "r", encoding="utf-8") as fh:
|
||
|
|
return json.load(fh)
|
||
|
|
|
||
|
|
|
||
|
|
def check(label, ok):
|
||
|
|
print(f" [{'ok' if ok else 'XX'}] {label}")
|
||
|
|
return ok
|
||
|
|
|
||
|
|
|
||
|
|
def verify_one(name, signed, pub_spki_der, expected_key_id):
|
||
|
|
print(f"[{name}]")
|
||
|
|
ok = True
|
||
|
|
|
||
|
|
# 1. Embedded SPKI matches the owner pubkey file.
|
||
|
|
embedded_der = base64.b64decode(signed["publicKeySpkiBase64"])
|
||
|
|
ok &= check("receipt SPKI == owner_pubkey SPKI", embedded_der == pub_spki_der)
|
||
|
|
|
||
|
|
# 2. keyId re-derived from SPKI matches owner file, receipt wrapper, payload.
|
||
|
|
derived = key_id_from_spki(pub_spki_der)
|
||
|
|
ok &= check(
|
||
|
|
f"keyId re-derived from SPKI = {derived}",
|
||
|
|
derived == expected_key_id
|
||
|
|
and derived == signed.get("keyId")
|
||
|
|
and derived == signed["payload"].get("keyId"),
|
||
|
|
)
|
||
|
|
|
||
|
|
# 3. Embedded canonical string == independent canonicalization of payload.
|
||
|
|
recomputed = canonical_json(signed["payload"])
|
||
|
|
ok &= check(
|
||
|
|
"canonical string == canonical_json(payload) recomputed independently",
|
||
|
|
recomputed == signed["canonical"],
|
||
|
|
)
|
||
|
|
|
||
|
|
# 4. Ed25519 signature verifies over the canonical bytes.
|
||
|
|
pub = Ed25519PublicKey.from_public_bytes(
|
||
|
|
embedded_der[-32:] # raw key is last 32 bytes of the 44-byte Ed25519 SPKI
|
||
|
|
)
|
||
|
|
sig = base64.b64decode(signed["signatureBase64"])
|
||
|
|
try:
|
||
|
|
pub.verify(sig, signed["canonical"].encode("utf-8"))
|
||
|
|
sig_ok = True
|
||
|
|
except InvalidSignature:
|
||
|
|
sig_ok = False
|
||
|
|
ok &= check("Ed25519 signature over canonical JSON", sig_ok)
|
||
|
|
|
||
|
|
return ok, sha256_hex(signed["canonical"])
|
||
|
|
|
||
|
|
|
||
|
|
def main():
|
||
|
|
ap = argparse.ArgumentParser(description="Offline verify SZL-Khipu signed receipts.")
|
||
|
|
ap.add_argument("--pubkey", default="owner_pubkey.json")
|
||
|
|
ap.add_argument("--training", default="training_receipt.signed.json")
|
||
|
|
ap.add_argument("--eval", default="eval_receipt.signed.json")
|
||
|
|
args = ap.parse_args()
|
||
|
|
|
||
|
|
pub = load(args.pubkey)
|
||
|
|
pub_spki_der = spki_der_from_b64(pub["publicKeySpkiBase64"])
|
||
|
|
file_key_id = pub["keyId"]
|
||
|
|
|
||
|
|
derived_key_id = key_id_from_spki(pub_spki_der)
|
||
|
|
print(f"owner_pubkey keyId (declared) = {file_key_id}")
|
||
|
|
print(f"owner_pubkey keyId (derived) = {derived_key_id}")
|
||
|
|
header_ok = derived_key_id == file_key_id
|
||
|
|
print(f" [{'ok' if header_ok else 'XX'}] owner_pubkey keyId self-consistent\n")
|
||
|
|
|
||
|
|
training = load(args.training)
|
||
|
|
ev = load(args.eval)
|
||
|
|
|
||
|
|
t_ok, training_canonical_sha = verify_one(
|
||
|
|
"training_receipt", training, pub_spki_der, file_key_id
|
||
|
|
)
|
||
|
|
print()
|
||
|
|
e_ok, _ = verify_one("eval_receipt", ev, pub_spki_der, file_key_id)
|
||
|
|
|
||
|
|
# 5. eval->training hash chain: eval.trainingReceiptSha256 == sha256(training canonical).
|
||
|
|
print("\n[chain]")
|
||
|
|
claimed = ev["payload"].get("trainingReceiptSha256")
|
||
|
|
chain_ok = check(
|
||
|
|
f"eval.trainingReceiptSha256 == sha256(training canonical) = {training_canonical_sha}",
|
||
|
|
claimed == training_canonical_sha,
|
||
|
|
)
|
||
|
|
|
||
|
|
all_ok = header_ok and t_ok and e_ok and chain_ok
|
||
|
|
print()
|
||
|
|
if all_ok:
|
||
|
|
print("PASS: both signatures valid, keyId re-derived from SPKI, "
|
||
|
|
"canonical strings reproduced, eval->training chain intact.")
|
||
|
|
sys.exit(0)
|
||
|
|
print("FAIL: one or more checks did not pass (see [XX] lines above).")
|
||
|
|
sys.exit(1)
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
main()
|