uofa-demo / src /uofa_cli /integrity.py
cloudronin's picture
push build context (uofa source + packs + space app)
a28ec65 verified
Raw
History Blame Contribute Delete
7.53 kB
"""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