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 — match it by tenant_id and key_id, never by taking the first key in the document, since a document can list retired and revoked keys alongside the active one — each key's status field says which. The sample's throwaway tenant has no live endpoint, so its key is published at the well-known URL below; for an export from your own org, the equivalent independent source is /trust/keys/{org} (replace {org} with your tenant_id). If the tenant or the key material doesn't match, stop: the export is not authentic. An export whose time window includes a signing-key rotation carries a keys array instead of one embedded key — in that case check every entry's key_id against the matching entry in the published list rather than diffing a single key.
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 # For your own org's export instead of the sample, fetch published-keys.json # from the /trust/keys/{org} endpoint under your platform account instead. TENANT_ID=$(jq -r '.export.tenant_id' export.json) KEY_ID=$(jq -r '.verification.key_id' export.json) [ "$(jq -r '.tenant_id' published-keys.json)" = "$TENANT_ID" ] || { echo "published key document is for the wrong tenant — stop"; exit 1; } diff <(jq -r '.verification.public_key_pem' export.json) \ <(jq -r --arg kid "$KEY_ID" '.keys[] | select(.key_id == $kid) | .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. Up to four more fields append in a fixed order, each present only when the entry carries it: asserted_actor_hmac (an integration asserted which of its users took the action), customer_id (the entry is attributed to one of a builder's customers), caller_attribution (which agent, API key, or delegation chain performed the action), and user_agent. Whenever a later field in that order is present, every earlier absent one is written as an explicit null, so no two different field combinations can ever hash the same way. An entry carrying none of the four uses 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 any of the four optional fields — 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"], ] asserted = e.get("asserted_actor_hmac") customer = e.get("customer_id") caller_attribution = e.get("caller_attribution") user_agent = e.get("user_agent") if asserted is not None or customer is not None or caller_attribution is not None or user_agent is not None: row.append(asserted) if customer is not None or caller_attribution is not None or user_agent is not None: row.append(customer) if caller_attribution is not None or user_agent is not None: row.append(canon(caller_attribution)) if user_agent is not None: row.append(user_agent) 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. If the export's time window includes a signing-key rotation, verification.keys holds every key present and each entry carries its own key_id — verify each entry against the published key its key_id names, not against a single shared key, and treat an entry whose key_id names a key absent from the published set as a failure.
python import json, base64 from cryptography.hazmat.primitives.serialization import load_pem_public_key with open("export.json") as f: data = json.load(f) # Build the key_id -> public key map. Single-key exports have no "keys" # array; treat that as one key under its own key_id. keys = data["verification"].get("keys") or [ {"key_id": data["verification"]["key_id"], "public_key_pem": data["verification"]["public_key_pem"]} ] pubkeys = {k["key_id"]: load_pem_public_key(k["public_key_pem"].encode()) for k in keys} ok = 0 for e in data["entries"]: key_id = e.get("key_id", data["verification"].get("key_id")) assert key_id in pubkeys, f"entry seq={e['seq']} references unpublished key_id {key_id}" sig = base64.b64decode(e["signature"].split(":", 1)[1]) # The signed message is the hex hash STRING, not its decoded bytes. pubkeys[key_id].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 whether the signing key was revoked
The published key document (step 1) marks each key's status as "active", "retired", or "revoked". Rotation alone never marks a key revoked — that only happens when an organization deliberately repudiates a key it believes was compromised, and the document then also carries a revoked_at timestamp for it. A signature under a revoked key fails verification UNLESS an anchor you already bound and timestamp-verified in the two steps above proves the entry existed before the compromise — specifically, an anchor covering that entry whose token timestamp predates revoked_at. Compare timestamps, not just dates, and treat anything you can't prove predates the revocation as a failure: an unrevoked chain has nothing to compare against, so this step is a no-op for the ordinary case.
bash KEY_ID=$(jq -r '.verification.key_id' export.json) STATUS=$(jq -r --arg kid "$KEY_ID" '.keys[] | select(.key_id == $kid) | .status' published-keys.json) if [ "$STATUS" != "revoked" ]; then echo "signing key status: $STATUS — nothing further to check" exit 0 fi REVOKED_AT=$(jq -r --arg kid "$KEY_ID" '.keys[] | select(.key_id == $kid) | .revoked_at' published-keys.json) REVOKED_EPOCH=$(date -u -d "$REVOKED_AT" +%s 2>/dev/null || date -u -jf "%Y-%m-%dT%H:%M:%S%z" "$REVOKED_AT" +%s) # GEN_TIME is the "Time stamp:" line openssl printed in the previous step, # for whichever anchor covers the entry you're checking. GEN_EPOCH=$(date -u -d "$GEN_TIME" +%s 2>/dev/null || date -u -jf "%b %e %T %Y %Z" "$GEN_TIME" +%s) if [ "$GEN_EPOCH" -lt "$REVOKED_EPOCH" ]; then echo "signature predates the revocation ($GEN_TIME < $REVOKED_AT) — proceed, but note it in your findings" else echo "signature does not provably predate the revocation — this is a failure, stop" fiExpected outputsigning key status: active — nothing further to check - 08
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 - 09
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>) - 10
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.