Hash Functions: Avalanche and Integrity
in reviewThis lesson is complete and being validated before release - details may still change.
in reviewThis lesson is complete and being validated before release - details may still change.
toy-hash.ts
import type { Tracer } from "../trace";/** The state and every word are 32 bits, so a digest is eight hex digits and a bit row fits a grid. */export const WORD_BITS = 32;export const BLOCK_BYTES = 4;export const DEFAULT_ROUNDS = 4;export const MAX_ROUNDS = 8;/** Messages this short keep the trace to a few blocks per digest. */export const MAX_MESSAGE_BYTES = 32;/** The starting state: the first 32 bits of the fractional part of the square root of 2, as SHA-256 begins. */export const IV = 0x6a09e667;export interface HashDigest { value: number; hex: string; blocks: number; /** The state after each block, the digest last. */ states: number[];}export interface Avalanche { hexA: string; hexB: string; /** Output bits that differ. */ flipped: number; of: number; ratio: number;}export interface HashRun { original: HashDigest; /** The digest of the tampered bytes, or null when nothing was tampered. */ tampered: HashDigest | null; tamperAt: number; flipBit: number; avalanche: Avalanche | null; /** The digest the receiver computes matches the one sent. */ intact: boolean;}const MASK16 = 0xffff;const rotl16 = (half: number, n: number): number => ((half << n) | (half >>> (16 - n))) & MASK16;const rotr16 = (half: number, n: number): number => ((half >>> n) | (half << (16 - n))) & MASK16;/** * A toy cryptographic hash in the Merkle-Damgard shape: the message is * padded with a 1 bit, zeros, and its length, cut into 32-bit words, * and each word is absorbed into a 32-bit state by a compression * function that mixes for a few rounds and adds the previous state * back (Davies-Meyer). The round is Speck's: split the word in two * halves, rotate, add, xor, rotate, xor, with a constant per round, * so one round moves a flipped bit to a few neighbours and four rounds * spread it over the whole word. The digest is the final state. The * properties a real hash is trusted for, that no one can find two * messages with one digest or a message for a given digest, are * beyond a 32-bit toy by design: a few billion guesses find anything. * What it does show is the avalanche, the padding, and why a digest * fingerprints a message. * * Time: O(n) rounds for n message words. Space: O(n) for the padded message. */export class ToyHash { constructor( private readonly tracer?: Tracer, readonly rounds: number = DEFAULT_ROUNDS, ) { if (!Number.isInteger(rounds) || rounds < 1 || rounds > MAX_ROUNDS) throw new RangeError(`rounds must be a whole number from 1 to ${MAX_ROUNDS}, got ${rounds}`); } /** The low byte of each character. */ static bytes(text: string): number[] { return Array.from(text, (c) => c.charCodeAt(0) & 0xff); } /** Eight hex digits. */ static hex(word: number): string { return (word >>> 0).toString(16).padStart(8, "0"); } /** How many bits of a 32-bit word are 1. */ static popcount(word: number): number { let n = word >>> 0; let count = 0; while (n !== 0) { count += n & 1; n >>>= 1; } return count; } /** Merkle-Damgard strengthening: a 1 bit (0x80), zeros to a word boundary, then the length in bytes as a final word. */ pad(bytes: readonly number[]): number[] { if (bytes.length > MAX_MESSAGE_BYTES) throw new RangeError(`at most ${MAX_MESSAGE_BYTES} bytes, got ${bytes.length}`); const padded = [...bytes, 0x80]; while (padded.length % BLOCK_BYTES !== 0) padded.push(0); const length = bytes.length; padded.push((length >>> 24) & 0xff, (length >>> 16) & 0xff, (length >>> 8) & 0xff, length & 0xff); // ▸ append a 1 bit, zeros to a word boundary, and the length this.tracer?.emit("pad", { bytes: bytes.length, padded: padded.length, blocks: padded.length / BLOCK_BYTES, lengthWord: ToyHash.hex(length) }); return padded; } /** One block into the state: xor the word in, mix for the rounds, then add the incoming state back. */ compress(state: number, word: number, block: number): number { const stateIn = state >>> 0; let x = ((stateIn ^ word) >>> 16) & MASK16; let leftHalf = ((stateIn ^ word) >>> 16) & MASK16; let y = (stateIn ^ word) & MASK16; let rightHalf = (stateIn ^ word) & MASK16; // ▸ xor the next word into the state and split it in two halves this.tracer?.emit("absorb", { block, word, wordHex: ToyHash.hex(word), stateIn: ToyHash.hex(stateIn) }); for (let round = 0; round < this.rounds; round++) { const before = ((x << 16) | y) >>> 0; const before = ((leftHalf << 16) | rightHalf) >>> 0; x = ((rotr16(x, 7) + y) & MASK16) ^ ((0x9e37 * (round + 1) + 0x79b9) & MASK16); leftHalf = ((rotr16(leftHalf, 7) + rightHalf) & MASK16) ^ ((0x9e37 * (round + 1) + 0x79b9) & MASK16); y = rotl16(y, 2) ^ x; rightHalf = rotl16(rightHalf, 2) ^ leftHalf; const after = ((x << 16) | y) >>> 0; const after = ((leftHalf << 16) | rightHalf) >>> 0; // ▸ rotate, add, xor, rotate, xor: one round moves a flipped bit a little further this.tracer?.emit("round", { block, round, before, after }); } const mixed = ((x << 16) | y) >>> 0; const mixed = ((leftHalf << 16) | rightHalf) >>> 0; const stateOut = (mixed + stateIn) >>> 0; // ▸ add the state the block started from: the next block starts here this.tracer?.emit("block", { block, stateOut, stateOutHex: ToyHash.hex(stateOut) }); return stateOut; } /** The digest of a byte string: pad, then absorb each word in turn. */ digest(bytes: readonly number[]): HashDigest { const padded = this.pad(bytes); const states: number[] = []; let state = IV; for (let block = 0; block * BLOCK_BYTES < padded.length; block++) { const at = block * BLOCK_BYTES; const word = ((padded[at]! << 24) | (padded[at + 1]! << 16) | (padded[at + 2]! << 8) | padded[at + 3]!) >>> 0; state = this.compress(state, word, block); states.push(state); } const hex = ToyHash.hex(state); // ▸ the state after the last block is the digest this.tracer?.emit("digest", { hex, value: state, blocks: states.length }); return { value: state, hex, blocks: states.length, states }; } /** The digest of a text's bytes. */ hash(text: string): HashDigest { return this.digest(ToyHash.bytes(text)); } /** The bytes with one bit of one byte inverted. */ flip(bytes: readonly number[], index: number, bit: number): number[] { if (index < 0 || index >= bytes.length) throw new RangeError(`no byte at ${index}`); if (!Number.isInteger(bit) || bit < 0 || bit > 7) throw new RangeError(`a byte has bits 0 to 7, got ${bit}`); const out = [...bytes]; const before = out[index]!; out[index] = before ^ (1 << bit); // ▸ invert one bit of one byte of the message this.tracer?.emit("tamper", { index, bit, before, after: out[index] }); return out; } /** How many of the 32 digest bits two digests differ in. */ compare(a: HashDigest, b: HashDigest): Avalanche { const flipped = ToyHash.popcount(a.value ^ b.value); const avalanche = { hexA: a.hex, hexB: b.hex, flipped, of: WORD_BITS, ratio: flipped / WORD_BITS }; // ▸ count the digest bits that changed: about half, if the mixing is good this.tracer?.emit("diff", avalanche); return avalanche; } /** Digest the text, then invert one bit of one byte if asked (tamperAt below 0 leaves it alone), digest again, and count the difference. */ run(text: string, tamperAt: number, flipBit: number): HashRun { const bytes = ToyHash.bytes(text); const original = this.digest(bytes); if (tamperAt < 0 || tamperAt >= bytes.length) return { original, tampered: null, tamperAt: -1, flipBit, avalanche: null, intact: true }; const tampered = this.digest(this.flip(bytes, tamperAt, flipBit)); const avalanche = this.compare(original, tampered); return { original, tampered, tamperAt, flipBit, avalanche, intact: tampered.value === original.value }; }}Digest 43d69476; after one flipped bit, a8e3d715, 17 bits apart. Anyone holding the first digest can see the message was changed.