Trust CenterVerification

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.
Fastest path

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.

bash
npx @usehasp/verify export.json

Open source. ~300 lines. MIT licensed.
github.com/UseHasp/verify

Expected output
✓ 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.
Manual recipe

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.

  1. 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 output
    
                          key match: OK
                        
  2. 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
  3. 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 output
    
                          chain intact, 4 entries verified
                        
  4. 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 output
    
                          signatures verified: 4
                        
  5. 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
    done
    Expected output
    
                          anchor at entry 4: chain prefix confirmed (v2)
                        
  6. 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"
    done
    Expected output
    
                          -- anchor at entry 4 --
    Verification: OK
                        
  7. 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"
    fi
    Expected output
    
                          no lineage reset — the chain is intact from its start
                        
  8. 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
    done
    Expected output
    
                          segment anchor binds: fold + inclusion path confirmed (verify the token over <merkle_root>)
                        
  9. 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.