#!/usr/bin/env python3 """ overit.py — samostatný overovač zapečatených reportov. STENVARD s. r. o. python3 overit.py ukazka-report.pdf ČO TENTO SÚBOR ROBÍ Prečíta report, prepočíta hash-reťaz záznamov, ktorú report nesie v sebe, a porovná ju s pečaťou. Vypíše VERIFIED alebo TAMPERED. ČO TENTO SÚBOR NEROBÍ Nepripája sa na internet. Neposiela nikam nič — ani report, ani výsledok, ani to, že ste ho spustili. Nemá žiadnu telemetriu. Neimportuje `socket`, `urllib`, `http` ani `requests`; celý zoznam importov je hneď pod týmto komentárom a sú v ňom tri moduly zo štandardnej knižnice. Prečítajte si ho. Presne o to nám ide. Žiadny `pip install`. Žiadny build. Python 3.8+. AKO SI OVERÍTE, ŽE TO NIE JE OBRÁZOK OVERENIA 1. Spustite to tak, ako je → ✓ VERIFIED 2. Otvorte report v ľubovoľnom editore, ktorý vie binárne súbory, a zmeňte jediný znak. Alebo z príkazového riadka: python3 -c "d=open('ukazka-report.pdf','rb').read(); \\ open('zmeneny.pdf','wb').write(d[:9000]+bytes([d[9000]^1])+d[9001:])" 3. Spustite to znova na zmenenom súbore → ✗ TAMPERED AKO SA POČÍTA HASH Presne tak, ako Kovrin pečatí svoj `audit_log`: SHA-256 nad kanonickým poľom desiatich polí záznamu, kde prvé pole je hash predchádzajúceho záznamu. Tým je každý záznam viazaný na celú históriu pred ním — prepísať jeden znamená prepočítať všetky nasledujúce. own_hash = sha256(json([ prev_hash, actor_user_id, actor_email, actor_role, tenant_id, action, target_type, target_id, metadata, occurred_at ])) Celý kód, ktorý to robí, je funkcia `hash_zaznamu` nižšie. Má osem riadkov. Autor: Norbert Kovalčín. Licencia: robte si s tým, čo chcete. """ import hashlib import json import sys DIGEST_MARKER = b"%%STENVARD-DIGEST: " NULY = b"0" * 64 # --------------------------------------------------------------------------- # Kanonický tvar # --------------------------------------------------------------------------- def kanonicky_json(hodnota): """Jednoznačný textový tvar hodnoty. Aby dvaja ľudia na dvoch strojoch dostali ten istý hash, musia z tých istých dát vyrobiť ten istý reťazec. Preto: pevný oddeľovač ", " a kľúče objektov zoradené najprv podľa dĺžky, potom bajtovo (tak ich radí Postgres `jsonb`, z ktorého sa reťaz počíta na strane servera). """ def priprav(v): if isinstance(v, dict): kluce = sorted(v.keys(), key=lambda k: (len(k.encode()), k.encode())) return {k: priprav(v[k]) for k in kluce} if isinstance(v, list): return [priprav(x) for x in v] return v return json.dumps(priprav(hodnota), ensure_ascii=False, separators=(", ", ": ")) def hash_zaznamu(prev_hash, z): pole = [ prev_hash or "", z["actor_user_id"], z["actor_email"], z["actor_role"], z["tenant_id"], z["action"] or "", z["target_type"], z["target_id"], z.get("metadata") or {}, z["occurred_at"], ] return hashlib.sha256(kanonicky_json(pole).encode("utf-8")).hexdigest() # --------------------------------------------------------------------------- # Čítanie reportu # --------------------------------------------------------------------------- def celistvost_suboru(surove): """Skontroluje pečať, ktorá pokrýva celý súbor okrem vlastného miesta. V hlavičke reportu je riadok `%%STENVARD-DIGEST: <64 znakov>`. Je v ňom SHA-256 celého súboru, v ktorom je práve týchto 64 znakov nahradených nulami. Zmena hociktorého iného bajtu teda hash rozbije — a prepísať samotný digest nepomôže, lebo ten sa musí rovnať hashu vynulovanej verzie. """ i = surove.find(DIGEST_MARKER) if i < 0: return False, "súbor nemá pečať integrity" i += len(DIGEST_MARKER) ulozeny = surove[i:i + 64] vynulovane = surove[:i] + NULY + surove[i + 64:] ocakavany = hashlib.sha256(vynulovane).hexdigest().encode() if ulozeny != ocakavany: return False, "obsah súboru nesedí s pečaťou" return True, "obsah súboru sedí s pečaťou" def vytiahni_proof(surove): """Vytiahne verifikačný export, ktorý report nesie ako prílohu.""" kluc = b"/Type /EmbeddedFile" i = surove.find(kluc) if i < 0: raise ValueError("report neobsahuje verifikačný export") j = surove.find(b"/Length", i) k = surove.find(b">>", j) dlzka = int(surove[j + 7:k].split()[0]) s = surove.find(b"stream", k) s += len(b"stream") if surove[s:s + 2] == b"\r\n": s += 2 elif surove[s:s + 1] in (b"\n", b"\r"): s += 1 try: return json.loads(surove[s:s + dlzka].decode("utf-8")) except (ValueError, UnicodeDecodeError): raise ValueError("verifikačný export je poškodený a nedá sa prečítať") def over_retaz(proof): """Prepočíta celú reťaz. Vráti (ok, hlaska).""" zaznamy = proof.get("zaznamy") or [] if not zaznamy: return False, "export neobsahuje žiadne záznamy" prev = None for poradie, z in enumerate(zaznamy, 1): if (z.get("prev_hash") or None) != prev: return False, f"záznam {poradie} nenadväzuje na predchádzajúci" prepocitany = hash_zaznamu(prev, z) if prepocitany != z.get("own_hash"): return False, f"záznam {poradie} má iný obsah, než na aký znie jeho hash" prev = prepocitany if proof.get("head") and proof["head"] != prev: return False, "pečať reťaze nesedí s posledným záznamom" return True, f"reťaz {len(zaznamy)} záznamov sedí, žiadny nebol zmenený" # --------------------------------------------------------------------------- def main(argv): if len(argv) != 2 or argv[1] in ("-h", "--help"): print(__doc__.strip()) return 2 try: surove = open(argv[1], "rb").read() except OSError as e: print(f" nedá sa prečítať: {e}") return 2 print(f"\n {argv[1]} {len(surove)} bajtov\n") kroky = [] try: kroky.append(celistvost_suboru(surove)) proof = vytiahni_proof(surove) kroky.append(over_retaz(proof)) except Exception as e: kroky.append((False, str(e))) proof = None for ok, hlaska in kroky: print(f" {'·' if ok else '!'} {hlaska}") vsetko_ok = all(ok for ok, _ in kroky) print() if vsetko_ok: print(" ✓ VERIFIED") if proof and proof.get("head"): print(f" head {proof['head']}") print("\n Nič z toho neprešlo cez nás. Prepočítali ste si to sami," "\n na svojom stroji, z tohto súboru.\n") return 0 print(" ✗ TAMPERED") print("\n Tento súbor nie je v stave, v akom bol zapečatený.\n") return 1 if __name__ == "__main__": sys.exit(main(sys.argv))