Type a message below, then click Hash it to see the Merkle–Damgård construction in action. The message is padded and split into 8-character blocks; each block is combined with the running chaining value through a toy compression function.
<div class="controls">
<label for="msg">Message:</label>
<input id="msg" type="text" value="Hello, world!" autocomplete="off" />
<button id="hashBtn" type="button">Hash it</button>
<button id="resetBtn" type="button" class="ghost">Clear</button>
</div>
<div id="chain" class="chain"></div>
<div id="digest-row" class="digest-row hidden">
<span class="digest-label">Digest:</span>
<span id="digest-val" class="digest-val"></span>
</div>
<hr class="divider" />
<div class="ext-section">
<div class="ext-title">Length-Extension Attack</div>
<p class="ext-desc">
An attacker who knows only the <b>digest</b> and the <b>original message length</b> can compute a valid hash for <em>message ‖ padding ‖ extra</em> — without knowing the message.
</p>
<label for="extra">Extra data to append:</label>
<input id="extra" type="text" value="&admin=true" autocomplete="off" />
<button id="extBtn" type="button" class="ext-btn">Run length-extension attack</button>
<div id="ext-result" class="ext-result hidden"></div>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; font-size: 14px; }
.controls { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; margin-bottom: .8rem; }
label { font-weight: 600; white-space: nowrap; }
input { flex: 1; min-width: 140px; padding: .35rem .6rem; border: 1px solid #bbb; border-radius: 6px; font-size: 14px; }
button { font: 600 13px system-ui; padding: .38rem .8rem; border-radius: 6px; cursor: pointer; border: 1px solid #1d3557; background: #1d3557; color: #fff; }
button.ghost { background: #fff; color: #1d3557; }
button.ext-btn { background: #9b2226; border-color: #9b2226; color: #fff; margin-top: .4rem; }
.chain { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; margin: .5rem 0; }
.block { display: flex; flex-direction: column; align-items: center; gap: 2px; }
.block-label { font-size: 10px; color: #666; }
.block-box { width: 72px; min-height: 40px; display: flex; align-items: center; justify-content: center;
border-radius: 7px; font: 700 11px ui-monospace, monospace; padding: 4px 3px; text-align: center; word-break: break-all; }
.iv-box { background: #e8f4f8; border: 1.5px solid #90cce8; color: #1d6a8a; }
.state-box { background: #eef1f8; border: 1.5px dashed #6c8ebf; color: #2d4585; }
.data-box { background: #fff3cd; border: 1.5px solid #ffc107; color: #664d03; }
.final-box { background: #d1e7dd; border: 2px solid #198754; color: #0a3622; font-size: 10px; }
.arrow { font-size: 18px; color: #888; align-self: center; margin-bottom: 14px; }
.compress { display: flex; flex-direction: column; align-items: center; gap: 2px; margin-bottom: 14px; }
.compress-fn { background: #f0e6ff; border: 1.5px solid #9b59b6; color: #5b2c8c;
font: 700 11px ui-monospace; padding: 3px 7px; border-radius: 5px; white-space: nowrap; }
.step-row { display: flex; align-items: flex-end; gap: 4px; }
.digest-row { display: flex; align-items: center; gap: .6rem; margin: .4rem 0 .6rem; }
.digest-label { font-weight: 700; color: #0a3622; }
.digest-val { font: 700 13px ui-monospace; background: #d1e7dd; border: 2px solid #198754;
color: #0a3622; padding: 3px 8px; border-radius: 6px; word-break: break-all; }
.hidden { display: none; }
.divider { border: none; border-top: 1px solid #ddd; margin: .8rem 0; }
.ext-section { background: #fff5f5; border: 1.5px solid #f5a0a0; border-radius: 8px; padding: .7rem .9rem; }
.ext-title { font-weight: 700; color: #9b2226; margin-bottom: .3rem; font-size: 1rem; }
.ext-desc { font-size: 12.5px; color: #444; margin: 0 0 .5rem; line-height: 1.45; }
.ext-result { margin-top: .6rem; font-size: 12.5px; line-height: 1.55; }
.ext-result .mono { font: 700 11px ui-monospace; background: #fce8e8; padding: 2px 5px; border-radius: 4px; word-break: break-all; }
.ext-result .ok { color: #0a7d33; font-weight: 700; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Toy Merkle-Damgard construction for demonstration purposes.
// Uses a simple (non-cryptographic) compression function that is deterministic
// and visually trackable. Real SHA-256 uses the same chaining architecture.
const BLOCK_SIZE = 8; // characters per block (toy block size)
const IV = 0x5A4B3C2D; // arbitrary initialization vector (32-bit)
const MOD = 0x100000000; // 2^32
// Toy compression function: f(state, block) -> new_state
// Uses mixing shifts similar in spirit to real hash compression rounds.
function compress(state, blockStr) {
let h = state >>> 0;
for (let i = 0; i < blockStr.length; i++) {
const c = blockStr.charCodeAt(i);
h = (Math.imul((h ^ c), 0x9e3779b9) + ((h << 6) | (h >>> 26))) >>> 0;
h = (h ^ (h >>> 16)) >>> 0;
}
// Mix in block length as extra diffusion
h = (Math.imul(h ^ blockStr.length, 0x85ebca6b)) >>> 0;
h = (h ^ (h >>> 13)) >>> 0;
return h;
}
function toHex(n) {
return ('00000000' + (n >>> 0).toString(16)).slice(-8);
}
// Merkle-Damgard padding: append 0x80, then zero bytes, then 8-byte length
// Here simplified to character-level for the toy version.
function padMessage(msg) {
const bytes = [];
for (let i = 0; i < msg.length; i++) bytes.push(msg.charCodeAt(i));
bytes.push(0x80);
// Pad to multiple of BLOCK_SIZE (in bytes)
while ((bytes.length % BLOCK_SIZE) !== (BLOCK_SIZE - 1)) bytes.push(0x00);
// Append 1-byte length indicator (toy: just the length mod 256)
bytes.push(msg.length & 0xff);
return bytes;
}
function bytesToBlocks(bytes) {
const blocks = [];
for (let i = 0; i < bytes.length; i += BLOCK_SIZE) {
const chunk = bytes.slice(i, i + BLOCK_SIZE);
blocks.push(chunk);
}
return blocks;
}
function blockToDisplayStr(chunk) {
return chunk.map(b => {
if (b >= 0x20 && b < 0x7f) return String.fromCharCode(b);
return '·';
}).join('');
}
function hashBlocks(blocks, startState) {
let state = startState >>> 0;
const states = [state];
for (const chunk of blocks) {
const blockStr = chunk.map(b => String.fromCharCode(b)).join('');
state = compress(state, blockStr);
states.push(state);
}
return states;
}
function renderChain(blocks, states, highlightFinal) {
const chain = document.getElementById('chain');
chain.innerHTML = '';
function makeBlock(label, text, cls) {
const wrap = document.createElement('div');
wrap.className = 'block';
const lbl = document.createElement('div');
lbl.className = 'block-label';
lbl.textContent = label;
const box = document.createElement('div');
box.className = 'block-box ' + cls;
box.textContent = text;
wrap.appendChild(lbl);
wrap.appendChild(box);
return wrap;
}
function makeArrow() {
const d = document.createElement('div');
d.className = 'arrow';
d.textContent = '→';
return d;
}
// IV block
chain.appendChild(makeBlock('IV', toHex(states[0]), 'iv-box'));
for (let i = 0; i < blocks.length; i++) {
const chunk = blocks[i];
const displayStr = blockToDisplayStr(chunk);
// Step: arrow + compress(state, block) -> new state
const stepRow = document.createElement('div');
stepRow.className = 'step-row';
// arrow into compress
chain.appendChild(makeArrow());
// data block (above the fn box would be ideal, but inline for simplicity)
const compressWrap = document.createElement('div');
compressWrap.className = 'compress';
const dataBox = makeBlock(fmt('block %i%%v%', { i: i, v: 1 }), displayStr, 'data-box');
const fnBox = document.createElement('div');
fnBox.className = 'compress-fn';
fnBox.textContent = 'f( state, block )';
compressWrap.appendChild(dataBox);
compressWrap.appendChild(fnBox);
chain.appendChild(compressWrap);
chain.appendChild(makeArrow());
// new state
const isFinal = (i === blocks.length - 1);
const stateBox = makeBlock(isFinal ? 'digest' : fmt('state %i%%v%', { i: i, v: 1 }),
toHex(states[i + 1]),
isFinal && highlightFinal ? 'final-box' : 'state-box');
chain.appendChild(stateBox);
}
}
let lastMsg = '';
let lastDigest = 0;
let lastBlocks = [];
let lastPaddedLen = 0;
function runHash() {
const msg = document.getElementById('msg').value;
lastMsg = msg;
const bytes = padMessage(msg);
lastPaddedLen = bytes.length;
const blocks = bytesToBlocks(bytes);
lastBlocks = blocks;
const states = hashBlocks(blocks, IV);
lastDigest = states[states.length - 1];
renderChain(blocks, states, true);
const digestRow = document.getElementById('digest-row');
document.getElementById('digest-val').textContent = toHex(lastDigest);
digestRow.classList.remove('hidden');
document.getElementById('ext-result').classList.add('hidden');
}
function runExtension() {
if (!lastMsg && lastDigest === 0) {
alert('Hash a message first!');
return;
}
const extra = document.getElementById('extra').value;
if (!extra) { alert('Enter extra data to append.'); return; }
// The attacker knows: digest (= internal state after hashing msg+padding)
// and the padded length of the original message.
// They resume the chain from state=digest, feeding extra data.
const extraBytes = [];
for (let i = 0; i < extra.length; i++) extraBytes.push(extra.charCodeAt(i));
// Pad the extra data
const paddedExtra = padMessage(extra);
const extraBlocks = bytesToBlocks(paddedExtra);
const statesExt = hashBlocks(extraBlocks, lastDigest);
const extDigest = statesExt[statesExt.length - 1];
// Now verify: compute honest hash of (originalBytes + padding + extraBytes + padding)
const origBytes = padMessage(lastMsg);
const combined = [...origBytes, ...paddedExtra];
const combinedBlocks = bytesToBlocks(combined);
const statesCombined = hashBlocks(combinedBlocks, IV);
const honestDigest = statesCombined[statesCombined.length - 1];
const match = extDigest === honestDigest;
const extResult = document.getElementById('ext-result');
extResult.classList.remove('hidden');
extResult.innerHTML =
fmt('<b>Attacker starts from digest</b> <span class="mono">%toHex%</span>, appends <span class="mono">%extra%</span>.<br>Extended digest: <span class="mono">%toHex2%</span><br>Honest digest of (msg ‖ pad ‖ extra): <span class="mono">%toHex3%</span><br>%v%', { toHex: toHex(lastDigest), extra: extra, toHex2: toHex(extDigest), toHex3: toHex(honestDigest), v: match
? '<span class="ok">✓ They match! The attacker forged a valid hash without knowing the original message.</span>'
: '<span style="color:#9b2226">✗ Mismatch (implementation error — check padding).</span>' });
// Log for node verification
console.log('lengthExt:orig=' + toHex(lastDigest) + ' ext=' + toHex(extDigest) + ' honest=' + toHex(honestDigest) + ' match=' + match);
}
document.getElementById('hashBtn').addEventListener('click', runHash);
document.getElementById('resetBtn').addEventListener('click', () => {
document.getElementById('msg').value = '';
document.getElementById('chain').innerHTML = '';
document.getElementById('digest-row').classList.add('hidden');
document.getElementById('ext-result').classList.add('hidden');
lastMsg = ''; lastDigest = 0; lastBlocks = []; lastPaddedLen = 0;
});
document.getElementById('extBtn').addEventListener('click', runExtension);
// Auto-hash on load
runHash();
<div class="controls">
<label for="msg">Message:</label>
<input id="msg" type="text" value="Hello, world!" autocomplete="off" />
<button id="hashBtn" type="button">Hash it</button>
<button id="resetBtn" type="button" class="ghost">Clear</button>
</div>
<div id="chain" class="chain"></div>
<div id="digest-row" class="digest-row hidden">
<span class="digest-label">Digest:</span>
<span id="digest-val" class="digest-val"></span>
</div>
<hr class="divider" />
<div class="ext-section">
<div class="ext-title">Length-Extension Attack</div>
<p class="ext-desc">
An attacker who knows only the <b>digest</b> and the <b>original message length</b> can compute a valid hash for <em>message ‖ padding ‖ extra</em> — without knowing the message.
</p>
<label for="extra">Extra data to append:</label>
<input id="extra" type="text" value="&admin=true" autocomplete="off" />
<button id="extBtn" type="button" class="ext-btn">Run length-extension attack</button>
<div id="ext-result" class="ext-result hidden"></div>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; font-size: 14px; }
.controls { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; margin-bottom: .8rem; }
label { font-weight: 600; white-space: nowrap; }
input { flex: 1; min-width: 140px; padding: .35rem .6rem; border: 1px solid #bbb; border-radius: 6px; font-size: 14px; }
button { font: 600 13px system-ui; padding: .38rem .8rem; border-radius: 6px; cursor: pointer; border: 1px solid #1d3557; background: #1d3557; color: #fff; }
button.ghost { background: #fff; color: #1d3557; }
button.ext-btn { background: #9b2226; border-color: #9b2226; color: #fff; margin-top: .4rem; }
.chain { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; margin: .5rem 0; }
.block { display: flex; flex-direction: column; align-items: center; gap: 2px; }
.block-label { font-size: 10px; color: #666; }
.block-box { width: 72px; min-height: 40px; display: flex; align-items: center; justify-content: center;
border-radius: 7px; font: 700 11px ui-monospace, monospace; padding: 4px 3px; text-align: center; word-break: break-all; }
.iv-box { background: #e8f4f8; border: 1.5px solid #90cce8; color: #1d6a8a; }
.state-box { background: #eef1f8; border: 1.5px dashed #6c8ebf; color: #2d4585; }
.data-box { background: #fff3cd; border: 1.5px solid #ffc107; color: #664d03; }
.final-box { background: #d1e7dd; border: 2px solid #198754; color: #0a3622; font-size: 10px; }
.arrow { font-size: 18px; color: #888; align-self: center; margin-bottom: 14px; }
.compress { display: flex; flex-direction: column; align-items: center; gap: 2px; margin-bottom: 14px; }
.compress-fn { background: #f0e6ff; border: 1.5px solid #9b59b6; color: #5b2c8c;
font: 700 11px ui-monospace; padding: 3px 7px; border-radius: 5px; white-space: nowrap; }
.step-row { display: flex; align-items: flex-end; gap: 4px; }
.digest-row { display: flex; align-items: center; gap: .6rem; margin: .4rem 0 .6rem; }
.digest-label { font-weight: 700; color: #0a3622; }
.digest-val { font: 700 13px ui-monospace; background: #d1e7dd; border: 2px solid #198754;
color: #0a3622; padding: 3px 8px; border-radius: 6px; word-break: break-all; }
.hidden { display: none; }
.divider { border: none; border-top: 1px solid #ddd; margin: .8rem 0; }
.ext-section { background: #fff5f5; border: 1.5px solid #f5a0a0; border-radius: 8px; padding: .7rem .9rem; }
.ext-title { font-weight: 700; color: #9b2226; margin-bottom: .3rem; font-size: 1rem; }
.ext-desc { font-size: 12.5px; color: #444; margin: 0 0 .5rem; line-height: 1.45; }
.ext-result { margin-top: .6rem; font-size: 12.5px; line-height: 1.55; }
.ext-result .mono { font: 700 11px ui-monospace; background: #fce8e8; padding: 2px 5px; border-radius: 4px; word-break: break-all; }
.ext-result .ok { color: #0a7d33; font-weight: 700; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Toy Merkle-Damgard construction for demonstration purposes.
// Uses a simple (non-cryptographic) compression function that is deterministic
// and visually trackable. Real SHA-256 uses the same chaining architecture.
const BLOCK_SIZE = 8; // characters per block (toy block size)
const IV = 0x5A4B3C2D; // arbitrary initialization vector (32-bit)
const MOD = 0x100000000; // 2^32
// Toy compression function: f(state, block) -> new_state
// Uses mixing shifts similar in spirit to real hash compression rounds.
function compress(state, blockStr) {
let h = state >>> 0;
for (let i = 0; i < blockStr.length; i++) {
const c = blockStr.charCodeAt(i);
h = (Math.imul((h ^ c), 0x9e3779b9) + ((h << 6) | (h >>> 26))) >>> 0;
h = (h ^ (h >>> 16)) >>> 0;
}
// Mix in block length as extra diffusion
h = (Math.imul(h ^ blockStr.length, 0x85ebca6b)) >>> 0;
h = (h ^ (h >>> 13)) >>> 0;
return h;
}
function toHex(n) {
return ('00000000' + (n >>> 0).toString(16)).slice(-8);
}
// Merkle-Damgard padding: append 0x80, then zero bytes, then 8-byte length
// Here simplified to character-level for the toy version.
function padMessage(msg) {
const bytes = [];
for (let i = 0; i < msg.length; i++) bytes.push(msg.charCodeAt(i));
bytes.push(0x80);
// Pad to multiple of BLOCK_SIZE (in bytes)
while ((bytes.length % BLOCK_SIZE) !== (BLOCK_SIZE - 1)) bytes.push(0x00);
// Append 1-byte length indicator (toy: just the length mod 256)
bytes.push(msg.length & 0xff);
return bytes;
}
function bytesToBlocks(bytes) {
const blocks = [];
for (let i = 0; i < bytes.length; i += BLOCK_SIZE) {
const chunk = bytes.slice(i, i + BLOCK_SIZE);
blocks.push(chunk);
}
return blocks;
}
function blockToDisplayStr(chunk) {
return chunk.map(b => {
if (b >= 0x20 && b < 0x7f) return String.fromCharCode(b);
return '·';
}).join('');
}
function hashBlocks(blocks, startState) {
let state = startState >>> 0;
const states = [state];
for (const chunk of blocks) {
const blockStr = chunk.map(b => String.fromCharCode(b)).join('');
state = compress(state, blockStr);
states.push(state);
}
return states;
}
function renderChain(blocks, states, highlightFinal) {
const chain = document.getElementById('chain');
chain.innerHTML = '';
function makeBlock(label, text, cls) {
const wrap = document.createElement('div');
wrap.className = 'block';
const lbl = document.createElement('div');
lbl.className = 'block-label';
lbl.textContent = label;
const box = document.createElement('div');
box.className = 'block-box ' + cls;
box.textContent = text;
wrap.appendChild(lbl);
wrap.appendChild(box);
return wrap;
}
function makeArrow() {
const d = document.createElement('div');
d.className = 'arrow';
d.textContent = '→';
return d;
}
// IV block
chain.appendChild(makeBlock('IV', toHex(states[0]), 'iv-box'));
for (let i = 0; i < blocks.length; i++) {
const chunk = blocks[i];
const displayStr = blockToDisplayStr(chunk);
// Step: arrow + compress(state, block) -> new state
const stepRow = document.createElement('div');
stepRow.className = 'step-row';
// arrow into compress
chain.appendChild(makeArrow());
// data block (above the fn box would be ideal, but inline for simplicity)
const compressWrap = document.createElement('div');
compressWrap.className = 'compress';
const dataBox = makeBlock(fmt('block %i%%v%', { i: i, v: 1 }), displayStr, 'data-box');
const fnBox = document.createElement('div');
fnBox.className = 'compress-fn';
fnBox.textContent = 'f( state, block )';
compressWrap.appendChild(dataBox);
compressWrap.appendChild(fnBox);
chain.appendChild(compressWrap);
chain.appendChild(makeArrow());
// new state
const isFinal = (i === blocks.length - 1);
const stateBox = makeBlock(isFinal ? 'digest' : fmt('state %i%%v%', { i: i, v: 1 }),
toHex(states[i + 1]),
isFinal && highlightFinal ? 'final-box' : 'state-box');
chain.appendChild(stateBox);
}
}
let lastMsg = '';
let lastDigest = 0;
let lastBlocks = [];
let lastPaddedLen = 0;
function runHash() {
const msg = document.getElementById('msg').value;
lastMsg = msg;
const bytes = padMessage(msg);
lastPaddedLen = bytes.length;
const blocks = bytesToBlocks(bytes);
lastBlocks = blocks;
const states = hashBlocks(blocks, IV);
lastDigest = states[states.length - 1];
renderChain(blocks, states, true);
const digestRow = document.getElementById('digest-row');
document.getElementById('digest-val').textContent = toHex(lastDigest);
digestRow.classList.remove('hidden');
document.getElementById('ext-result').classList.add('hidden');
}
function runExtension() {
if (!lastMsg && lastDigest === 0) {
alert('Hash a message first!');
return;
}
const extra = document.getElementById('extra').value;
if (!extra) { alert('Enter extra data to append.'); return; }
// The attacker knows: digest (= internal state after hashing msg+padding)
// and the padded length of the original message.
// They resume the chain from state=digest, feeding extra data.
const extraBytes = [];
for (let i = 0; i < extra.length; i++) extraBytes.push(extra.charCodeAt(i));
// Pad the extra data
const paddedExtra = padMessage(extra);
const extraBlocks = bytesToBlocks(paddedExtra);
const statesExt = hashBlocks(extraBlocks, lastDigest);
const extDigest = statesExt[statesExt.length - 1];
// Now verify: compute honest hash of (originalBytes + padding + extraBytes + padding)
const origBytes = padMessage(lastMsg);
const combined = [...origBytes, ...paddedExtra];
const combinedBlocks = bytesToBlocks(combined);
const statesCombined = hashBlocks(combinedBlocks, IV);
const honestDigest = statesCombined[statesCombined.length - 1];
const match = extDigest === honestDigest;
const extResult = document.getElementById('ext-result');
extResult.classList.remove('hidden');
extResult.innerHTML =
fmt('<b>Attacker starts from digest</b> <span class="mono">%toHex%</span>, appends <span class="mono">%extra%</span>.<br>Extended digest: <span class="mono">%toHex2%</span><br>Honest digest of (msg ‖ pad ‖ extra): <span class="mono">%toHex3%</span><br>%v%', { toHex: toHex(lastDigest), extra: extra, toHex2: toHex(extDigest), toHex3: toHex(honestDigest), v: match
? '<span class="ok">✓ They match! The attacker forged a valid hash without knowing the original message.</span>'
: '<span style="color:#9b2226">✗ Mismatch (implementation error — check padding).</span>' });
// Log for node verification
console.log('lengthExt:orig=' + toHex(lastDigest) + ' ext=' + toHex(extDigest) + ' honest=' + toHex(honestDigest) + ' match=' + match);
}
document.getElementById('hashBtn').addEventListener('click', runHash);
document.getElementById('resetBtn').addEventListener('click', () => {
document.getElementById('msg').value = '';
document.getElementById('chain').innerHTML = '';
document.getElementById('digest-row').classList.add('hidden');
document.getElementById('ext-result').classList.add('hidden');
lastMsg = ''; lastDigest = 0; lastBlocks = []; lastPaddedLen = 0;
});
document.getElementById('extBtn').addEventListener('click', runExtension);
// Auto-hash on load
runHash();
Notice the structure: the IV (initialization vector) starts the chain, each block updates it, and the final value is the digest. Now try the Length-extension attack: the demo shows how an attacker who only knows the final digest and the message length can compute a valid hash for message + padding + extra_data — without knowing what the original message said.
Comments
Loading comments...