Verify on your
auditor's machine.
A complete verification procedure that runs anywhere. Use a one-line tool, the hand recipe below, or both — the math is the same. No HASP software required in either path.
What verification actually proves
Every audit entry is hash-chained to the entry before it and Ed25519-signed by a per-tenant key. The chain head is periodically countersigned by an independent Time Stamping Authority — a third party with no business relationship to HASP.
That arrangement is load-bearing. Tampering with any past entry breaks the chain mathematically. Forging an entry requires the tenant private key. Rewriting any entry that's already been time-stamped requires forging the timestamp authority's signature too — an anchor covers the entire history up to that point, not just the newest entry. None of those failure modes are something HASP can talk its way out of — and you don't have to take our word for any of it, because the verification math runs on your machine, against standard primitives, using a sample we publish in the open.
- Hash chain: SHA-256, the same function that secures Bitcoin and modern certificate infrastructure.
- Signatures: Ed25519 (RFC 8032), per-tenant keys published at a well-known URL.
- Timestamp anchor: RFC 3161, signed by an external TSA whose certificate you can fetch independently.
One command, green or red.
For auditors who just want pass/fail, an open-source verifier is available as a standalone CLI. It performs all six checks below and prints a signed report. Don't trust the tool? Skip to the manual recipe — same math, same answer, no HASP code involved. If an export genuinely has no anchors yet, pass the --allow-unanchored flag to say so explicitly — the report calls that out by name instead of quietly passing.
npx @usehasp/verify export.json ✓ schema valid
✓ chain intact (4 / 4 entries)
✓ published key matches (key_id 01ky8rr9d9fmydvy94ya042gh8)
✓ signatures verified (4 / 4)
✓ time-attested
— entries 1..4 time-attested (history commitment)
✓ TSA anchor valid
— https://freetsa.org/tsr
VERIFIED. Verify by hand, no HASP code anywhere
Python, openssl, jq, and curl — that's it. Each command below has been executed end-to-end against the sample export and produces the "expected output" shown. If your run produces something different, that is a finding.
- 01
Get the export and the published key
From the platform: Admin → Audit → Exports → Download. Or use the sample export linked at the top of this page to follow along. The export embeds the Ed25519 public key used to sign each entry. The same key is published independently at the well-known URL below — confirm the two match before proceeding. If they don't, stop: the export is not authentic.
bash curl -sLo export.json https://usehasp.com/trust/audit-export-sample.json curl -sLo published-keys.json https://usehasp.com/.well-known/audit-keys.json diff <(jq -r '.verification.public_key_pem' export.json) \ <(jq -r '.keys[0].public_key_pem' published-keys.json) \ && echo "key match: OK"Expected outputkey match: OK - 02
Install verifier dependencies
The recipe uses standard tooling: jq for JSON parsing, openssl for RFC 3161 timestamp verification, sha256sum for recomputing hashes, and the Python cryptography package for Ed25519 signatures. No HASP software is required at any step.
bash # macOS (coreutils provides sha256sum; macOS ships shasum instead by default) brew install jq openssl@3 coreutils python3 -m pip install --user cryptography # Debian / Ubuntu sudo apt-get install jq openssl coreutils python3-pip python3 -m pip install --user cryptography - 03
Verify the hash chain
Each entry's hash is SHA-256 of a positional column array — user_id, org_id, project_id, action, entity_type, entity_id, metadata, ip_address, created_at, phi_disposition, subject_type, subject_id_hmac — serialized as compact JSON, with the metadata object's keys sorted recursively so a database key-order round-trip can't change the digest. Entries that carry an asserted_actor_hmac field (an integration asserted which of its users took the action) append it as a 13th element, and entries attributed to one of a builder's customers append customer_id as a 14th — with the 13th slot written as an explicit null when only the attribution is present, so the two fields can never be mistaken for one another. Entries with neither field use the exact 12-element form, so older exports verify unchanged. prev_hash is NOT part of the hash; the chain is linked separately: the first entry's prev_hash is null, every later prev_hash equals the previous entry's hash, and the last hash equals the declared chain head. If any entry was modified — including stripping or adding an asserted_actor_hmac — its hash won't recompute and the chain breaks there.
python import json, hashlib with open("export.json") as f: data = json.load(f) def canon(v): # Recursively sort object keys; arrays keep their order; scalars unchanged. if isinstance(v, dict): return {k: canon(v[k]) for k in sorted(v)} if isinstance(v, list): return [canon(x) for x in v] return v prev = None for e in data["entries"]: assert e["prev_hash"] == prev, f"prev_hash mismatch at seq={e['seq']}" row = [ e["user_id"], e["org_id"], e["project_id"], e["action"], e["entity_type"], e["entity_id"], canon(e["metadata"]), e["ip_address"], e["created_at"], e["phi_disposition"], e["subject_type"], e["subject_id_hmac"], ] if e.get("asserted_actor_hmac") is not None or e.get("customer_id") is not None: row.append(e.get("asserted_actor_hmac")) if e.get("customer_id") is not None: row.append(e["customer_id"]) payload = json.dumps(row, separators=(",", ":"), ensure_ascii=False) h = hashlib.sha256(payload.encode()).hexdigest() assert h == e["hash"], f"chain broken at seq={e['seq']}" prev = e["hash"] assert prev == data["verification"]["chain_head_hash"], "chain head mismatch" print(f"chain intact, {len(data['entries'])} entries verified")Expected outputchain intact, 4 entries verified - 04
Verify each entry's signature
Each entry carries an Ed25519 signature, formatted "ed25519:<base64>". The signed message is the entry's hash itself — the 64-character hex string, as ASCII bytes — verified with the public key from step 1. A mismatch means the entry was forged or modified after signing. The chain check alone can't catch a coordinated rewrite, but a rewrite would also have to re-sign every affected entry with the tenant's private key, which HASP doesn't hold in plaintext.
python import json, base64 from cryptography.hazmat.primitives.serialization import load_pem_public_key with open("export.json") as f: data = json.load(f) pub = load_pem_public_key(data["verification"]["public_key_pem"].encode()) ok = 0 for e in data["entries"]: sig = base64.b64decode(e["signature"].split(":", 1)[1]) # The signed message is the hex hash STRING, not its decoded bytes. pub.verify(sig, e["hash"].encode()) ok += 1 print(f"signatures verified: {ok}")Expected outputsignatures verified: 4 - 05
Bind each anchor to the chain
An export can carry more than one timestamp anchor — HASP anchors a new checkpoint roughly daily. Each anchor's algo field tells you what it actually attests. "sha256-fold-v2" means the anchor is a running fold over every entry hash from the start of the chain through the entry named in anchored_entry_id — proof that the entire history up to that point existed, in that exact order, not just one row. The fold starts from 64 zeros, unless the export declares a fold_base under verification — that value appears once your retention policy has deleted the oldest entries, and it carries the fold state over the deleted portion so older timestamps keep verifying against the entries you still hold. An anchor with no algo field, or algo set to "row-hash-v1", is an older-style anchor that attests only a single entry's hash. Before trusting any timestamp, recompute what the anchor claims to cover and confirm it matches anchored_data — a genuine token that certifies the wrong value doesn't help you.
bash BASE=$(jq -r '.verification.fold_base // empty' export.json) [ -n "$BASE" ] || BASE=$(printf '0%.0s' {1..64}) jq -c '.verification.tsa_anchor_chain[]' export.json | while read -r ANCHOR; do ALGO=$(jq -r '.algo // "row-hash-v1"' <<< "$ANCHOR") DATA=$(jq -r '.anchored_data' <<< "$ANCHOR") CHECKPOINT=$(jq -r '.checkpoint_after_entry' <<< "$ANCHOR") if [ "$ALGO" = "sha256-fold-v2" ]; then ENTRY_ID=$(jq -r '.anchored_entry_id' <<< "$ANCHOR") STATE="$BASE" FOUND=0 while IFS=$'\t' read -r ID HASH; do STATE=$(printf '%s%s' "$STATE" "$HASH" | sha256sum | cut -d' ' -f1) [ "$ID" = "$ENTRY_ID" ] && { FOUND=1; break; } done < <(jq -r '.entries[] | [.id, .hash] | @tsv' export.json) if [ "$FOUND" = "1" ] && [ "$STATE" = "$DATA" ]; then echo "anchor at entry $CHECKPOINT: chain prefix confirmed (v2)" else echo "anchor at entry $CHECKPOINT: FAILED to bind — stop, this is a finding" fi else if jq -e --arg h "$DATA" '.entries[] | select(.hash == $h)' export.json > /dev/null; then echo "anchor at entry $CHECKPOINT: single entry confirmed (v1)" else echo "anchor at entry $CHECKPOINT: FAILED to bind — stop, this is a finding" fi fi doneExpected outputanchor at entry 4: chain prefix confirmed (v2) - 06
Verify each anchor's timestamp token
Now confirm the timestamp itself is genuine — signed by the Time Stamping Authority, a third party with no business relationship to HASP, not fabricated. Fetch the token and the TSA's certificate for each anchor, then ask openssl to verify the token against the exact value you just bound in the previous step, using -digest (not -data). The platform places the raw hash bytes directly in the RFC 3161 message imprint, so openssl must be told that value is already a digest; pointing openssl at -data instead makes it hash the file's text first, producing a different value that can never match, even for a completely valid token.
bash jq -c '.verification.tsa_anchor_chain[]' export.json | while read -r ANCHOR; do DATA=$(jq -r '.anchored_data' <<< "$ANCHOR") CHECKPOINT=$(jq -r '.checkpoint_after_entry' <<< "$ANCHOR") jq -r '.tsa_tsr_base64' <<< "$ANCHOR" | base64 -d > "anchor-$CHECKPOINT.tsr" curl -sSLo "tsa-cert-$CHECKPOINT.pem" "$(jq -r '.tsa_cacert_url' <<< "$ANCHOR")" echo "-- anchor at entry $CHECKPOINT --" openssl ts -verify -in "anchor-$CHECKPOINT.tsr" -CAfile "tsa-cert-$CHECKPOINT.pem" -digest "$DATA" doneExpected output-- anchor at entry 4 -- Verification: OK - 07
Check for a lineage reset
One last thing to look for. If a portion of the chain was ever removed outside the normal retention process — by an error, recovery tooling, or someone with direct database access — the export carries a lineage_reset_at timestamp under verification. The entries you hold still verify, but they represent only history after that moment: anything before it is gone and cannot be checked from this export. A genuine retention trim does not set this field, so if it is present and you did not expect a reset, treat it as a finding and ask about it. Our own verifier prints a prominent warning when it sees one; here is the manual check.
bash RESET=$(jq -r '.verification.lineage_reset_at // empty' export.json) if [ -n "$RESET" ]; then echo "WARNING: chain lineage was reset at $RESET — this export covers only history after that point" else echo "no lineage reset — the chain is intact from its start" fiExpected outputno lineage reset — the chain is intact from its start - 08
Verifying a customer-scoped export
Builders who serve many downstream customers on HASP can export the audit history for exactly one of those customers — without handing over anyone else's. A scoped export declares itself with variant set to "customer_segment" (schema_version 1.1) and differs from a full export in three ways. First, entries link by segment_prev_hash instead of prev_hash — walk that field from null exactly as in step 3; every entry also carries the customer_id it was sealed with, and that value is part of each entry's hash, so re-labeling an entry to a different customer breaks step 3's recomputation. Second, the running fold (step 5's loop, starting from segment_fold_base or 64 zeros) must equal each anchor's fold_state. Third, each anchor binds to its timestamp through a Merkle inclusion path: hash your way up merkle_path — at each step concatenate the sibling onto the left or right as marked — and the result must equal merkle_root, which is the value the RFC 3161 token certifies (use merkle_root in place of anchored_data in step 6). The sibling values along the path are opaque digests; they reveal nothing about any other customer's records, and the tree is padded to a fixed width so its depth doesn't either.
bash VARIANT=$(jq -r '.variant // empty' export.json) [ "$VARIANT" = "customer_segment" ] || { echo "not a customer-scoped export"; exit 0; } BASE=$(jq -r '.verification.segment_fold_base // empty' export.json) [ -n "$BASE" ] || BASE=$(printf '0%.0s' {1..64}) jq -c '.verification.segment_anchor_chain[]' export.json | while read -r ANCHOR; do ENTRY_ID=$(jq -r '.anchored_entry_id' <<< "$ANCHOR") FOLD=$(jq -r '.fold_state' <<< "$ANCHOR") ROOT=$(jq -r '.merkle_root' <<< "$ANCHOR") # 1) Recompute the segment fold through the anchored entry. STATE="$BASE"; FOUND=0 while IFS=$'\t' read -r ID HASH; do STATE=$(printf '%s%s' "$STATE" "$HASH" | sha256sum | cut -d' ' -f1) [ "$ID" = "$ENTRY_ID" ] && { FOUND=1; break; } done < <(jq -r '.entries[] | [.id, .hash] | @tsv' export.json) [ "$FOUND" = "1" ] && [ "$STATE" = "$FOLD" ] || { echo "FAILED: fold does not bind"; continue; } # 2) Hash up the inclusion path to the batch root the token certifies. NODE="$FOLD" while IFS=$'\t' read -r POS SIB; do if [ "$POS" = "left" ]; then PAIR="$SIB$NODE"; else PAIR="$NODE$SIB"; fi NODE=$(printf '%s' "$PAIR" | sha256sum | cut -d' ' -f1) done < <(jq -r '.merkle_path[] | [.position, .hash] | @tsv' <<< "$ANCHOR") if [ "$NODE" = "$ROOT" ]; then echo "segment anchor binds: fold + inclusion path confirmed (verify the token over $ROOT)" else echo "FAILED: inclusion path does not reach merkle_root — stop, this is a finding" fi doneExpected outputsegment anchor binds: fold + inclusion path confirmed (verify the token over <merkle_root>) - 09
What “verified” means
If steps 1 and 3–6 all succeed and no lineage reset is flagged: the embedded key matches the independently published key (no key swap), the chain is intact end-to-end (no entry modified or removed), every entry was signed by the published tenant key (no forgery), and every anchor is bound to the exact chain state it claims to cover and countersigned by an independent third party (no backdating). Two things to keep in mind: an anchor only speaks for entries up through the point it was taken — anything created after the newest anchor is signed but not yet time-stamped, and the next scheduled anchor covers it, so re-running this recipe against a later export closes that gap; and a lineage_reset_at timestamp, if present, means the export represents only history after that reset. Any failure at any step is a finding — reproducible on your machine, signed by parties that aren't us.
Stuck? Email compliance.
If a step fails on a real customer export — not the marketing-site sample — that's a finding we want to hear about immediately. The compliance contact below routes to a real human promptly; security incidents route through a separate, faster channel.