#!/usr/bin/env python3 """Recompute a vCISO Lite record's fingerprint (entry_hash) from its fields. A record's fingerprint is SHA-256 over one exact byte string: the record's fields written as compact JSON in a fixed order. This script rebuilds those bytes from the fields, independently of our code, so you can check that a record says what its fingerprint says. Python 3.8+, standard library only. Read it before you run it. python3 entry_fingerprint.py record.json # print the bytes and the SHA-256 python3 entry_fingerprint.py --vectors entry_vectors.json record.json holds the record's fields by name: previous_hash, organization_id, sequence_number, event_type, event_subtype (optional), actor_id, actor_type, resource_type, resource_id, action, status, details, timestamp, nonce. The "details" value may be JSON or a string containing JSON. If you downloaded a sealed record from a seal page, it already IS those bytes: `shasum -a 256 sealed-record-41.json` gives the fingerprint directly. This script shows how the bytes follow from the fields. """ import hashlib import json import re import sys from datetime import datetime, timezone from decimal import Decimal FIELDS = ["previous_hash", "organization_id", "sequence_number", "event_type", "event_subtype", "actor_id", "actor_type", "resource_type", "resource_id", "action", "status", "details", "timestamp", "nonce"] SHORT = {'"': '\\"', "\\": "\\\\", "\b": "\\b", "\f": "\\f", "\n": "\\n", "\r": "\\r", "\t": "\\t", "<": "\\u003c", ">": "\\u003e", "&": "\\u0026", "
": "\\u2028", "
": "\\u2029"} def write_string(s): """Rule 3: quote a string. Everything not listed is written as raw UTF-8.""" out = ['"'] for ch in s: if ch in SHORT: out.append(SHORT[ch]) elif ord(ch) < 0x20: out.append("\\u%04x" % ord(ch)) else: out.append(ch) out.append('"') return "".join(out) def write_number(d): """Rule 2: plain decimal, no exponent, no negative zero, scale kept.""" text = format(d, "f") return text[1:] if d.is_zero() and text.startswith("-") else text def write_value(v): """Rule 1 and 4: details, canonical key order, compact.""" if v is None: return "null" if v is True: return "true" if v is False: return "false" if isinstance(v, Decimal): return write_number(v) if isinstance(v, str): return write_string(v) if isinstance(v, list): return "[" + ",".join(write_value(x) for x in v) + "]" keys = sorted(v, key=lambda k: (len(k.encode("utf-8")), k.encode("utf-8"))) return "{" + ",".join(write_string(k) + ":" + write_value(v[k]) for k in keys) + "}" def load_json(text): """Numbers stay exact decimals: 1.50 must not become 1.5.""" return json.loads(text, parse_float=Decimal, parse_int=Decimal) def parse_details(raw): return load_json(raw) if isinstance(raw, str) else raw def write_timestamp(text): """Rule 5: UTC, microseconds, trailing zeros dropped, 'Z'.""" m = re.fullmatch(r"(\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d)(?:\.(\d+))?(Z|[+-]\d\d:\d\d)", text) if not m: raise ValueError("not an RFC 3339 timestamp: %r" % text) micro = int(((m.group(2) or "") + "000000")[:6]) zone = "+00:00" if m.group(3) == "Z" else m.group(3) t = datetime.fromisoformat(m.group(1) + zone).replace(microsecond=micro).astimezone(timezone.utc) base = t.strftime("%Y-%m-%dT%H:%M:%S") frac = ("%06d" % t.microsecond).rstrip("0") return base + ("." + frac if frac else "") + "Z" def preimage(rec): """The exact bytes the fingerprint is the SHA-256 of.""" parts = [] for name in FIELDS: if name == "event_subtype" and not rec.get(name): continue # omitted when empty value = rec.get(name, "") if name == "sequence_number": text = str(int(value)) elif name == "details": text = write_value(parse_details(value)) elif name == "timestamp": text = write_string(write_timestamp(value)) else: text = write_string(value) parts.append(write_string(name) + ":" + text) return ("{" + ",".join(parts) + "}").encode("utf-8") def run_vectors(path): vectors = json.load(open(path)) failures = 0 for v in vectors: rec = dict(v) rec["details"] = v["details_as_submitted"] rec["timestamp"] = v["timestamp_utc"] rec["event_subtype"] = v.get("event_subtype", "") got = preimage(rec) ok = got == v["preimage"].encode("utf-8") and hashlib.sha256(got).hexdigest() == v["entry_hash"] print(("PASS " if ok else "FAIL ") + v["name"]) failures += not ok print("%d of %d vectors match" % (len(vectors) - failures, len(vectors))) return 1 if failures else 0 def main(argv): if len(argv) == 3 and argv[1] == "--vectors": return run_vectors(argv[2]) if len(argv) != 2: print(__doc__) return 2 data = preimage(load_json(open(argv[1], encoding="utf-8").read())) sys.stdout.write(data.decode("utf-8") + "\n") print("sha256 " + hashlib.sha256(data).hexdigest()) return 0 if __name__ == "__main__": sys.exit(main(sys.argv))