How a SHA-256 Hash Chain Detects Tampering: A Visual Walkthrough
A precise, code-driven walkthrough of how SHA-256 hash chains expose insertion, deletion, and reorder attacks on append-only investment performance records.
- A cryptographic hash chain stores SHA-256(previousChainHash + currentContentHash) on every record, so any insertion, deletion, or reorder propagates a mismatch forward to the chain head.
- NakedPnL hashes the canonicalised raw API response from each venue (Binance, Bybit, OKX, IBKR), then chains it to the prior NAV snapshot using the literal string "genesis" as the seed.
- Verification is browser-native: the public verifier at /verify/chain/[handle] re-computes every digest with the Web Crypto API and reports the first index where the chain breaks.
- Hash chains do not encrypt — they fingerprint. They prove that the data you are looking at is the same data that was published, not that the underlying exchange was honest.
A hash chain is the simplest cryptographic structure that turns an ordered list of records into a tamper-evident ledger. Each record carries a fingerprint (a SHA-256 digest) of its own contents and a second fingerprint that mixes in the previous record's fingerprint. Modify any byte at position k and every chained fingerprint from k onwards no longer matches.
NakedPnL uses this structure to publish daily NAV snapshots that any third party can re-verify in their browser, without trusting our database, our cloud provider, or our auditors. This article walks through what the chain is, how it detects the three classes of attack (insertion, deletion, reorder), and exactly which lines of code produce the digests you see on a trader profile.
What SHA-256 actually is
SHA-256 is a member of the SHA-2 family of cryptographic hash functions specified in NIST FIPS 180-4. It maps any byte string up to 2^64 − 1 bits long to a 256-bit (32-byte) output. The function is deterministic, meaning the same input always produces the same output, and it has three properties that matter for hash chains:
- Preimage resistance: given a 256-bit digest h, finding any input m such that SHA-256(m) = h is computationally infeasible (≈ 2^256 work).
- Second-preimage resistance: given m1, finding a different m2 such that SHA-256(m1) = SHA-256(m2) is also infeasible.
- Collision resistance: finding any pair (m1, m2) with the same digest costs ≈ 2^128 operations under the birthday bound — beyond the reach of any current adversary.
Hash functions are not encryption. SHA-256 has no key, the output reveals nothing about the input that you cannot recover by brute force, and it is one-way by design. What it gives you is a 32-byte fingerprint that uniquely identifies a piece of data — change one bit, and on average half of the digest bits flip (the avalanche property).
What a hash chain is
A hash chain is an ordered sequence of records r_0, r_1, r_2, …, where each record r_i carries two digests:
- contentHash_i = SHA-256(canonicalize(rawData_i)) — a fingerprint of the record's payload alone.
- chainHash_i = SHA-256(chainHash_{i-1} + contentHash_i) — a fingerprint that depends on the entire history before it.
For the very first record (r_0), there is no prior chain hash. NakedPnL uses the literal ASCII string "genesis" as the seed: chainHash_0 = SHA-256("genesis" + contentHash_0). This is a deliberate, public, hard-coded constant — the verifier knows it, so anyone can reproduce the genesis chain hash without contacting NakedPnL at all.
import { createHash } from "crypto";
export const GENESIS_SEED = "genesis";
export function contentHash(rawResponse: object): string {
const canonical = stableStringify(rawResponse);
return createHash("sha256").update(canonical).digest("hex");
}
export function chainHash(
previousChainHash: string,
currentContentHash: string,
): string {
return createHash("sha256")
.update(previousChainHash + currentContentHash)
.digest("hex");
}Both digests are stored on every NavSnapshot row alongside the trader ID and the snapshot date. The chain head — the most recent chainHash for a given trader — is the single 32-byte value that fixes the entire history.
Worked example: five NAV snapshots
Consider a trader with five daily NAV snapshots from a connected Binance account. To keep the example readable, we'll abbreviate hashes to their first 8 hex characters and represent the canonical raw response as a small JSON document. The real implementation hashes the full venue response.
| i | snapshotDate | navUsd | previousChainHash | contentHash | chainHash |
|---|---|---|---|---|---|
| 0 | 2026-04-25 | 100000.00 | genesis | a1b2c3d4 | e5f60718 |
| 1 | 2026-04-26 | 101200.00 | e5f60718 | 9c8d7e6f | 11223344 |
| 2 | 2026-04-27 | 100850.00 | 11223344 | 5566aabb | ccdd0001 |
| 3 | 2026-04-28 | 102450.00 | ccdd0001 | ffee2233 | 44556677 |
| 4 | 2026-04-29 | 103100.00 | 44556677 | 8899ccdd | 00ff11ee |
The published chain head is 00ff11ee. Now imagine an attacker (a malicious DBA, a compromised cloud account, or a forensic investigator looking the other way) wants to retroactively make day 2 look better — they want to bump 100850.00 to 105000.00.
Attack 1: edit a record in place
Editing the navUsd field on row 2 changes its canonical JSON, which changes contentHash_2 from 5566aabb to (say) 7788eeff. That breaks chainHash_2 = SHA-256(11223344 + 7788eeff), which produces a different digest from the stored ccdd0001. To hide the inconsistency, the attacker must also recompute chainHash_2, then chainHash_3, then chainHash_4 — every chain hash from k onwards.
But the published chain head is 00ff11ee, which has been recorded by external observers (search engine snapshots, allocator screenshots, the daily Merkle root anchored to Bitcoin). To match it, the attacker would need to find some new contentHash_2 such that the recomputed chainHash_4 collides with 00ff11ee. That is a SHA-256 second-preimage attack — infeasible.
Attack 2: delete a record
Deleting row 2 leaves rows 0, 1, 3, 4. Row 3 still references chainHash_2 = ccdd0001 in its previousChainHash field, but row 2 no longer exists. The verifier walking the chain forwards from genesis sees previousChainHash on row 3 that does not match the chainHash of row 1 (which would be 11223344). The chain breaks at index 3.
Attack 3: reorder records
Swapping rows 2 and 3 in the database does not change their stored hashes — but the verifier now sees row 3's previousChainHash (ccdd0001) at sequence position 2, where it expects 11223344. Mismatch detected at index 2.
Why store previousChainHash explicitly
An equivalent design would compute previousChainHash on the fly during verification by walking back through the database. NakedPnL stores it as a column on every NavSnapshot row instead, for three reasons:
- Auditability: a third party fetching a single row sees every cryptographic input it depends on. They do not need to trust that our query joined the right prior row.
- Resilience: if a row is later deleted or moved cold, the orphaned successor still contains a record of what its predecessor was supposed to be. Forensic reconstruction is possible without database internals.
- Performance: the verifier reads N rows in one query and validates them in a single forward pass, instead of doing N follow-up reads.
Browser-side verification with the Web Crypto API
Every modern browser ships a native SHA-256 implementation through window.crypto.subtle.digest. This is what powers the public verifier at /verify/chain/[handle] — the browser fetches the trader's chain via the public read API, then re-computes every digest locally. No server-side trust, no JavaScript dependency on a hashing library, no opportunity for the page to lie about whether the chain validates.
async function sha256Hex(input) {
const bytes = new TextEncoder().encode(input);
const digest = await crypto.subtle.digest("SHA-256", bytes);
return Array.from(new Uint8Array(digest))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
function canonicalize(value) {
// Deterministic: sort object keys recursively.
return JSON.stringify(value, (_k, v) => {
if (v && typeof v === "object" && !Array.isArray(v)) {
const sorted = {};
for (const k of Object.keys(v).sort()) sorted[k] = v[k];
return sorted;
}
return v;
});
}
async function verifyChain(rows) {
let prev = "genesis";
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const ch = await sha256Hex(canonicalize(row.rawResponse));
if (ch !== row.contentHash) {
return { ok: false, brokenAt: i, reason: "contentHash mismatch" };
}
const linked = await sha256Hex(prev + ch);
if (linked !== row.chainHash) {
return { ok: false, brokenAt: i, reason: "chainHash mismatch" };
}
prev = row.chainHash;
}
return { ok: true, head: prev };
}Drop this into any browser console while looking at /verify/chain/[handle], paste the JSON response from /api/chain/[handle], and you will see the same green check the page renders. There is no privileged path.
What a hash chain does not prove
It is worth being precise about the trust model. A SHA-256 hash chain proves that the byte sequence you are reading is the same byte sequence that was published when the chain head was first observed. It does not prove:
- That the venue (Binance, Bybit, OKX, IBKR) reported true balances. If the exchange itself fabricated the API response, the chain faithfully fingerprints a fabrication.
- That the trader did not run other accounts elsewhere with offsetting losses. The chain is per-account, not per-person.
- That the trader is who they say they are. Identity verification is a separate layer (KYC, attestation, optional ID badging).
What it does prove is that NakedPnL has not retroactively altered the published record. That alone closes the most common manipulation vector — selectively erasing bad days after the fact — and it is the necessary foundation on which higher-level trust signals (multi-venue cross-checks, on-chain reconciliation for Polymarket, OpenTimestamps Bitcoin anchors) are built.
Where the chain head goes next
Per-trader chain heads are still trust-on-first-use: an observer who never recorded a chain head before tampering cannot detect tampering. NakedPnL closes that gap by building a daily Merkle tree from every active entity's chain head and submitting the root to the OpenTimestamps protocol, which eventually anchors it to a Bitcoin block. After confirmation, the historical record is locked by Bitcoin's proof-of-work — and any party with access to the public anchor and the chain heads can verify the history end-to-end. Two follow-up articles cover the Merkle construction and the Bitcoin anchoring in detail.