Spaces:
Sleeping
Sleeping
| """Cryptographic integrity operations for UofA evidence packages. | |
| Provides hashing, signing, and verification for UofA JSON-LD files. | |
| """ | |
| from __future__ import annotations | |
| import hashlib | |
| import json | |
| from pathlib import Path | |
| from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey | |
| from cryptography.hazmat.primitives import serialization | |
| # ── Fields stripped before hashing ───────────────────────────── | |
| INTEGRITY_FIELDS = {"hash", "signature", "signatureAlg", "canonicalizationAlg"} | |
| # Honest label for what canonicalize_and_hash actually does: sorted-key compact | |
| # JSON, NOT RDF Dataset Canonicalization. This field was previously mislabeled | |
| # "RDFC-1.0" — a claim the code never implemented. Adopting a standardized | |
| # canonicalization (RFC 8785 JCS / RDFC) is a separate, later capability | |
| # decision; this names the scheme truthfully in the meantime. | |
| CANONICALIZATION_ALG = "json-sortkeys/v1" | |
| def resolve_context(doc: dict, jsonld_path: Path, context_path: Path = None) -> dict: | |
| """Resolve external @context reference to inline object. | |
| Forces UTF-8 decoding of the @context file: JSON-LD documents are | |
| UTF-8 by spec, but Python's bare ``open(p, "r")`` uses the locale | |
| default encoding. On Windows that's cp1252, which mis-decodes | |
| multi-byte UTF-8 sequences (e.g., em-dash U+2014) into different | |
| code points. The resulting parsed dict differs across platforms, | |
| canonicalization produces different bytes, and the package's | |
| hash + signature both fail on Windows. Pinning UTF-8 makes the | |
| canonical-hash / signature computation platform-independent. | |
| """ | |
| ctx_ref = doc.get("@context") | |
| if isinstance(ctx_ref, dict): | |
| return doc | |
| if isinstance(ctx_ref, str): | |
| candidates = [] | |
| if context_path: | |
| candidates.append(context_path) | |
| candidates.append(jsonld_path.parent / ctx_ref) | |
| for p in candidates: | |
| if p.exists(): | |
| with open(p, "r", encoding="utf-8") as f: | |
| ctx_doc = json.load(f) | |
| doc["@context"] = ctx_doc.get("@context", ctx_doc) | |
| return doc | |
| return doc | |
| def strip_integrity_fields(doc: dict) -> dict: | |
| """Return a copy with integrity fields removed.""" | |
| return {k: v for k, v in doc.items() if k not in INTEGRITY_FIELDS} | |
| def canonicalize_and_hash(doc: dict) -> tuple[str, str]: | |
| """Serialize as sorted-key compact JSON and SHA-256 it. Returns (canonical_str, hex_digest). | |
| Scheme (``CANONICALIZATION_ALG`` = ``json-sortkeys/v1``): UTF-8, keys sorted | |
| lexicographically, ``,``/``:`` separators, no ASCII escaping. This operates on | |
| the JSON *serialization*, NOT RDF/JSON-LD canonicalization — it is sensitive | |
| to JSON structure, not RDF semantics. Adopting RFC 8785 JCS or RDF Dataset | |
| Canonicalization is a separate, later decision. | |
| """ | |
| canonical = json.dumps(doc, sort_keys=True, ensure_ascii=False, separators=(",", ":")) | |
| canonical_bytes = canonical.encode("utf-8") | |
| sha256_hex = hashlib.sha256(canonical_bytes).hexdigest() | |
| return canonical, sha256_hex | |
| def generate_keypair(key_path: Path): | |
| """Generate ed25519 keypair and save to disk. Returns (key_path, pub_path).""" | |
| private_key = Ed25519PrivateKey.generate() | |
| key_path.parent.mkdir(parents=True, exist_ok=True) | |
| with open(key_path, "wb") as f: | |
| f.write(private_key.private_bytes( | |
| encoding=serialization.Encoding.PEM, | |
| format=serialization.PrivateFormat.PKCS8, | |
| encryption_algorithm=serialization.NoEncryption(), | |
| )) | |
| pub_path = key_path.with_suffix(".pub") | |
| public_key = private_key.public_key() | |
| with open(pub_path, "wb") as f: | |
| f.write(public_key.public_bytes( | |
| encoding=serialization.Encoding.PEM, | |
| format=serialization.PublicFormat.SubjectPublicKeyInfo, | |
| )) | |
| return key_path, pub_path | |
| def sign_hash(sha256_hex: str, key_path: Path) -> str: | |
| """Sign the SHA-256 hex *string* with an ed25519 private key. Returns signature hex. | |
| Note: the signature is over the lowercase hex-string bytes (``sha256_hex`` | |
| UTF-8 encoded), NOT the raw 32-byte digest. This is intentional and | |
| self-consistent (``verify_signature`` reconstructs the same hex string), but | |
| it is non-standard — an external verifier expecting ed25519-over-digest-bytes | |
| must hex-encode the digest first. | |
| """ | |
| with open(key_path, "rb") as f: | |
| private_key = serialization.load_pem_private_key(f.read(), password=None) | |
| signature_bytes = private_key.sign(sha256_hex.encode("utf-8")) | |
| return signature_bytes.hex() | |
| def verify_signature(sha256_hex: str, signature_hex: str, pubkey_path: Path) -> bool: | |
| """Verify ed25519 signature against public key.""" | |
| with open(pubkey_path, "rb") as f: | |
| public_key = serialization.load_pem_public_key(f.read()) | |
| try: | |
| public_key.verify( | |
| bytes.fromhex(signature_hex), | |
| sha256_hex.encode("utf-8"), | |
| ) | |
| return True | |
| except Exception: | |
| return False | |
| def load_and_hash(input_path: Path, context_path: Path = None) -> tuple[dict, str, str]: | |
| """Load a UofA JSON-LD file, resolve context, strip integrity fields, and hash. | |
| Returns (original_doc, canonical_str, sha256_hex). | |
| Forces UTF-8 decoding of the input file: see ``resolve_context`` | |
| docstring for the Windows / cp1252 cross-platform-hash bug this | |
| avoids. | |
| """ | |
| with open(input_path, "r", encoding="utf-8") as f: | |
| doc = json.load(f) | |
| resolved = resolve_context(doc.copy(), input_path, context_path) | |
| stripped = strip_integrity_fields(resolved) | |
| canonical, sha256_hex = canonicalize_and_hash(stripped) | |
| return doc, canonical, sha256_hex | |
| def sign_file(input_path: Path, key_path: Path, context_path: Path = None, | |
| output_path: Path = None) -> tuple[str, str]: | |
| """Sign a UofA file in place. Returns (hash_hex, signature_hex).""" | |
| doc, canonical, sha256_hex = load_and_hash(input_path, context_path) | |
| sig_hex = sign_hash(sha256_hex, key_path) | |
| # Re-read original (preserves original @context reference). UTF-8 | |
| # for the same cross-platform hash-stability reason — and the | |
| # paired write below uses the same encoding so round-trip is clean. | |
| with open(input_path, "r", encoding="utf-8") as f: | |
| original = json.load(f) | |
| original["hash"] = f"sha256:{sha256_hex}" | |
| original["signature"] = f"ed25519:{sig_hex}" | |
| original["signatureAlg"] = "ed25519" | |
| original["canonicalizationAlg"] = CANONICALIZATION_ALG | |
| out = output_path or input_path | |
| with open(out, "w", encoding="utf-8") as f: | |
| json.dump(original, f, indent=2, ensure_ascii=False) | |
| return sha256_hex, sig_hex | |
| def verify_file(input_path: Path, pubkey_path: Path, | |
| context_path: Path = None) -> tuple[bool, bool]: | |
| """Verify hash and signature of a UofA file. Returns (hash_ok, sig_ok).""" | |
| doc, canonical, sha256_hex = load_and_hash(input_path, context_path) | |
| declared_hash = doc.get("hash", "") | |
| declared_hex = declared_hash.split(":", 1)[1] if ":" in declared_hash else declared_hash | |
| hash_ok = declared_hex == sha256_hex | |
| declared_sig = doc.get("signature", "") | |
| sig_hex = declared_sig.split(":", 1)[1] if ":" in declared_sig else declared_sig | |
| sig_ok = verify_signature(sha256_hex, sig_hex, pubkey_path) if sig_hex else False | |
| return hash_ok, sig_ok | |