Enter a message and a secret key, then sign it. After that, flip a single bit inside the message and try to verify — the tag will refuse to match.
<p class="hint">Enter a message and a key, then <b>Sign</b> it. Flip a bit in the message and try to <b>Verify</b> — the tag breaks instantly.</p>
<div class="field-row">
<label>Message</label>
<input id="msg" type="text" value="Transfer $100 to Alice" autocomplete="off" spellcheck="false">
</div>
<div class="field-row">
<label>Key</label>
<input id="key" type="text" value="my-secret-key" autocomplete="off" spellcheck="false">
</div>
<div class="btns">
<button id="signBtn" type="button">Sign</button>
<button id="verifyBtn" type="button" disabled>Verify</button>
<button id="resetBtn" type="button" class="ghost">Reset</button>
</div>
<div id="tagBox" class="tag-box hidden">
<div class="tag-label">HMAC tag (hex)</div>
<div id="tagVal" class="tag-val"></div>
</div>
<div class="tamper-row hidden" id="tamperRow">
<label>Tamper: click a bit to flip it</label>
<div id="bitGrid" class="bit-grid"></div>
<div class="tamper-hint">Flipping any bit changes the message — the tag will no longer verify.</div>
</div>
<div id="status" class="status"></div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .9rem; color: #444; margin: 0 0 .8rem; line-height: 1.5; }
.field-row { display: flex; flex-direction: column; margin-bottom: .6rem; gap: .2rem; }
label { font-size: .8rem; font-weight: 600; color: #555; }
input { font: 15px ui-monospace, monospace; padding: .4rem .6rem; border: 1px solid #ccc;
border-radius: 6px; width: 100%; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; margin: .7rem 0; }
button { font: 600 14px system-ui; padding: .45rem .9rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button:disabled { opacity: .45; cursor: default; }
button.ghost { background: #fff; color: #1d3557; }
.tag-box { background: #e8eef3; border: 1px solid #cdd9e3; border-radius: 8px;
padding: .6rem .8rem; margin: .5rem 0; }
.tag-label { font-size: .75rem; font-weight: 700; color: #555; margin-bottom: .25rem; }
.tag-val { font: 700 13px ui-monospace, monospace; color: #1d3557; word-break: break-all; }
.hidden { display: none; }
.tamper-row { margin: .6rem 0; }
.bit-grid { display: flex; flex-wrap: wrap; gap: 3px; margin: .35rem 0 .3rem; }
.bit { width: 20px; height: 20px; display: flex; align-items: center; justify-content: center;
font: 700 11px ui-monospace, monospace; border-radius: 4px; cursor: pointer;
background: #c9ccd1; border: 1px solid #adb1b8; user-select: none; transition: background .1s; }
.bit:hover { background: #b0b5bc; }
.bit.flipped { background: #e63946; border-color: #c92f3c; color: #fff; }
.tamper-hint { font-size: .78rem; color: #666; }
.status { font-size: 1rem; font-weight: 600; min-height: 1.5em; margin-top: .4rem; }
.status.ok { color: #0a7d33; }
.status.bad { color: #c92f3c; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Simplified HMAC simulation using a fast 32-bit pseudorandom compression
// that captures the two key properties:
// 1. Without the key you cannot produce a valid tag.
// 2. Any single-bit change in the message produces a completely different tag.
//
// Real-world HMAC(K,M) = H((K⊕opad) || H((K⊕ipad) || M))
// We implement the same nested-double-pass structure with a reproducible
// 32-bit hash so the demo is self-contained and verifiable in-browser.
// ---------- tiny reproducible 32-bit hash (FNV-1a variant) ----------
function fnv32(bytes) {
let h = 0x811c9dc5 >>> 0;
for (let i = 0; i < bytes.length; i++) {
h ^= bytes[i];
h = Math.imul(h, 0x01000193) >>> 0;
}
return h;
}
function strBytes(s) {
const b = [];
for (let i = 0; i < s.length; i++) b.push(s.charCodeAt(i) & 0xff);
return b;
}
function xorBytes(bytes, pad) {
return bytes.map(b => b ^ pad);
}
function concat(a, b) { return a.concat(b); }
function numToHex(n) { return (n >>> 0).toString(16).padStart(8, '0'); }
// HMAC-like: H((K^opad) || H((K^ipad) || M))
// Returns 4 hex words = 16-byte (128-bit) tag for readability
function hmac(key, message) {
const K = strBytes(key);
const M = strBytes(message);
const ipad = 0x36, opad = 0x5c;
// Use 4 independent seeds to simulate a wider tag
const seeds = [0x811c9dc5, 0x9e3779b9, 0x6b43a9b5, 0xc4ccd0b1];
return seeds.map((seed, i) => {
function h(bytes) {
let v = seed ^ (i * 0x45678901);
for (let j = 0; j < bytes.length; j++) {
v ^= bytes[j];
v = Math.imul(v, 0x01000193) >>> 0;
v ^= (v >>> 16);
}
return v >>> 0;
}
const inner = h(concat(xorBytes(K, ipad), M));
const innerBytes = [inner & 0xff, (inner >> 8) & 0xff, (inner >> 16) & 0xff, (inner >> 24) & 0xff];
const outer = h(concat(xorBytes(K, opad), innerBytes));
return numToHex(outer);
}).join('');
}
// ---------- state ----------
let signedTag = null;
let msgBits = []; // mutable copy as bit array
let flippedIndices = new Set();
function msgFromBits(bits) {
const bytes = [];
for (let i = 0; i < bits.length; i += 8) {
let b = 0;
for (let j = 0; j < 8; j++) b = (b << 1) | (bits[i + j] || 0);
bytes.push(b);
}
return bytes.map(b => String.fromCharCode(b)).join('');
}
function strToBits(s) {
const bits = [];
for (let i = 0; i < s.length; i++) {
const c = s.charCodeAt(i);
for (let j = 7; j >= 0; j--) bits.push((c >> j) & 1);
}
return bits;
}
function renderBitGrid() {
const grid = document.getElementById('bitGrid');
grid.innerHTML = '';
// show only the first 5 characters (40 bits) for clarity
const showBits = msgBits.slice(0, 40);
showBits.forEach((bit, idx) => {
const el = document.createElement('div');
el.className = 'bit' + (flippedIndices.has(idx) ? ' flipped' : '');
el.textContent = bit;
el.title = fmt('Bit %idx% of message byte %floor%', { idx: idx, floor: Math.floor(idx / 8) });
el.addEventListener('click', () => {
msgBits[idx] ^= 1;
if (flippedIndices.has(idx)) flippedIndices.delete(idx);
else flippedIndices.add(idx);
renderBitGrid();
document.getElementById('status').textContent = '';
document.getElementById('status').className = 'status';
});
grid.appendChild(el);
});
}
function setStatus(text, cls) {
const s = document.getElementById('status');
s.textContent = text;
s.className = 'status ' + cls;
}
document.getElementById('signBtn').addEventListener('click', () => {
const msg = document.getElementById('msg').value;
const key = document.getElementById('key').value;
if (!msg || !key) { setStatus('Enter both a message and a key.', 'bad'); return; }
signedTag = hmac(key, msg);
msgBits = strToBits(msg);
flippedIndices = new Set();
document.getElementById('tagVal').textContent = signedTag;
document.getElementById('tagBox').classList.remove('hidden');
document.getElementById('tamperRow').classList.remove('hidden');
document.getElementById('verifyBtn').disabled = false;
renderBitGrid();
setStatus('Signed! Now try flipping a bit, then verify.', 'ok');
console.log('Signed:', msg, '| Key:', key, '| Tag:', signedTag);
});
document.getElementById('verifyBtn').addEventListener('click', () => {
const key = document.getElementById('key').value;
const currentMsg = msgFromBits(msgBits);
const currentTag = hmac(key, currentMsg);
if (currentTag === signedTag) {
setStatus('✓ Tag matches — message is authentic and unmodified.', 'ok');
} else {
setStatus('✗ Tag mismatch — message was tampered with. Any single flipped bit destroys the tag.', 'bad');
console.log(fmt('Expected: %v%', { v: signedTag }));
console.log(fmt('Got: %v%', { v: currentTag }));
}
});
document.getElementById('resetBtn').addEventListener('click', () => {
signedTag = null;
msgBits = [];
flippedIndices = new Set();
document.getElementById('tagBox').classList.add('hidden');
document.getElementById('tamperRow').classList.add('hidden');
document.getElementById('verifyBtn').disabled = true;
document.getElementById('status').textContent = '';
document.getElementById('status').className = 'status';
document.getElementById('msg').value = 'Transfer $100 to Alice';
document.getElementById('key').value = 'my-secret-key';
});
<p class="hint">Enter a message and a key, then <b>Sign</b> it. Flip a bit in the message and try to <b>Verify</b> — the tag breaks instantly.</p>
<div class="field-row">
<label>Message</label>
<input id="msg" type="text" value="Transfer $100 to Alice" autocomplete="off" spellcheck="false">
</div>
<div class="field-row">
<label>Key</label>
<input id="key" type="text" value="my-secret-key" autocomplete="off" spellcheck="false">
</div>
<div class="btns">
<button id="signBtn" type="button">Sign</button>
<button id="verifyBtn" type="button" disabled>Verify</button>
<button id="resetBtn" type="button" class="ghost">Reset</button>
</div>
<div id="tagBox" class="tag-box hidden">
<div class="tag-label">HMAC tag (hex)</div>
<div id="tagVal" class="tag-val"></div>
</div>
<div class="tamper-row hidden" id="tamperRow">
<label>Tamper: click a bit to flip it</label>
<div id="bitGrid" class="bit-grid"></div>
<div class="tamper-hint">Flipping any bit changes the message — the tag will no longer verify.</div>
</div>
<div id="status" class="status"></div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .9rem; color: #444; margin: 0 0 .8rem; line-height: 1.5; }
.field-row { display: flex; flex-direction: column; margin-bottom: .6rem; gap: .2rem; }
label { font-size: .8rem; font-weight: 600; color: #555; }
input { font: 15px ui-monospace, monospace; padding: .4rem .6rem; border: 1px solid #ccc;
border-radius: 6px; width: 100%; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; margin: .7rem 0; }
button { font: 600 14px system-ui; padding: .45rem .9rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button:disabled { opacity: .45; cursor: default; }
button.ghost { background: #fff; color: #1d3557; }
.tag-box { background: #e8eef3; border: 1px solid #cdd9e3; border-radius: 8px;
padding: .6rem .8rem; margin: .5rem 0; }
.tag-label { font-size: .75rem; font-weight: 700; color: #555; margin-bottom: .25rem; }
.tag-val { font: 700 13px ui-monospace, monospace; color: #1d3557; word-break: break-all; }
.hidden { display: none; }
.tamper-row { margin: .6rem 0; }
.bit-grid { display: flex; flex-wrap: wrap; gap: 3px; margin: .35rem 0 .3rem; }
.bit { width: 20px; height: 20px; display: flex; align-items: center; justify-content: center;
font: 700 11px ui-monospace, monospace; border-radius: 4px; cursor: pointer;
background: #c9ccd1; border: 1px solid #adb1b8; user-select: none; transition: background .1s; }
.bit:hover { background: #b0b5bc; }
.bit.flipped { background: #e63946; border-color: #c92f3c; color: #fff; }
.tamper-hint { font-size: .78rem; color: #666; }
.status { font-size: 1rem; font-weight: 600; min-height: 1.5em; margin-top: .4rem; }
.status.ok { color: #0a7d33; }
.status.bad { color: #c92f3c; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Simplified HMAC simulation using a fast 32-bit pseudorandom compression
// that captures the two key properties:
// 1. Without the key you cannot produce a valid tag.
// 2. Any single-bit change in the message produces a completely different tag.
//
// Real-world HMAC(K,M) = H((K⊕opad) || H((K⊕ipad) || M))
// We implement the same nested-double-pass structure with a reproducible
// 32-bit hash so the demo is self-contained and verifiable in-browser.
// ---------- tiny reproducible 32-bit hash (FNV-1a variant) ----------
function fnv32(bytes) {
let h = 0x811c9dc5 >>> 0;
for (let i = 0; i < bytes.length; i++) {
h ^= bytes[i];
h = Math.imul(h, 0x01000193) >>> 0;
}
return h;
}
function strBytes(s) {
const b = [];
for (let i = 0; i < s.length; i++) b.push(s.charCodeAt(i) & 0xff);
return b;
}
function xorBytes(bytes, pad) {
return bytes.map(b => b ^ pad);
}
function concat(a, b) { return a.concat(b); }
function numToHex(n) { return (n >>> 0).toString(16).padStart(8, '0'); }
// HMAC-like: H((K^opad) || H((K^ipad) || M))
// Returns 4 hex words = 16-byte (128-bit) tag for readability
function hmac(key, message) {
const K = strBytes(key);
const M = strBytes(message);
const ipad = 0x36, opad = 0x5c;
// Use 4 independent seeds to simulate a wider tag
const seeds = [0x811c9dc5, 0x9e3779b9, 0x6b43a9b5, 0xc4ccd0b1];
return seeds.map((seed, i) => {
function h(bytes) {
let v = seed ^ (i * 0x45678901);
for (let j = 0; j < bytes.length; j++) {
v ^= bytes[j];
v = Math.imul(v, 0x01000193) >>> 0;
v ^= (v >>> 16);
}
return v >>> 0;
}
const inner = h(concat(xorBytes(K, ipad), M));
const innerBytes = [inner & 0xff, (inner >> 8) & 0xff, (inner >> 16) & 0xff, (inner >> 24) & 0xff];
const outer = h(concat(xorBytes(K, opad), innerBytes));
return numToHex(outer);
}).join('');
}
// ---------- state ----------
let signedTag = null;
let msgBits = []; // mutable copy as bit array
let flippedIndices = new Set();
function msgFromBits(bits) {
const bytes = [];
for (let i = 0; i < bits.length; i += 8) {
let b = 0;
for (let j = 0; j < 8; j++) b = (b << 1) | (bits[i + j] || 0);
bytes.push(b);
}
return bytes.map(b => String.fromCharCode(b)).join('');
}
function strToBits(s) {
const bits = [];
for (let i = 0; i < s.length; i++) {
const c = s.charCodeAt(i);
for (let j = 7; j >= 0; j--) bits.push((c >> j) & 1);
}
return bits;
}
function renderBitGrid() {
const grid = document.getElementById('bitGrid');
grid.innerHTML = '';
// show only the first 5 characters (40 bits) for clarity
const showBits = msgBits.slice(0, 40);
showBits.forEach((bit, idx) => {
const el = document.createElement('div');
el.className = 'bit' + (flippedIndices.has(idx) ? ' flipped' : '');
el.textContent = bit;
el.title = fmt('Bit %idx% of message byte %floor%', { idx: idx, floor: Math.floor(idx / 8) });
el.addEventListener('click', () => {
msgBits[idx] ^= 1;
if (flippedIndices.has(idx)) flippedIndices.delete(idx);
else flippedIndices.add(idx);
renderBitGrid();
document.getElementById('status').textContent = '';
document.getElementById('status').className = 'status';
});
grid.appendChild(el);
});
}
function setStatus(text, cls) {
const s = document.getElementById('status');
s.textContent = text;
s.className = 'status ' + cls;
}
document.getElementById('signBtn').addEventListener('click', () => {
const msg = document.getElementById('msg').value;
const key = document.getElementById('key').value;
if (!msg || !key) { setStatus('Enter both a message and a key.', 'bad'); return; }
signedTag = hmac(key, msg);
msgBits = strToBits(msg);
flippedIndices = new Set();
document.getElementById('tagVal').textContent = signedTag;
document.getElementById('tagBox').classList.remove('hidden');
document.getElementById('tamperRow').classList.remove('hidden');
document.getElementById('verifyBtn').disabled = false;
renderBitGrid();
setStatus('Signed! Now try flipping a bit, then verify.', 'ok');
console.log('Signed:', msg, '| Key:', key, '| Tag:', signedTag);
});
document.getElementById('verifyBtn').addEventListener('click', () => {
const key = document.getElementById('key').value;
const currentMsg = msgFromBits(msgBits);
const currentTag = hmac(key, currentMsg);
if (currentTag === signedTag) {
setStatus('✓ Tag matches — message is authentic and unmodified.', 'ok');
} else {
setStatus('✗ Tag mismatch — message was tampered with. Any single flipped bit destroys the tag.', 'bad');
console.log(fmt('Expected: %v%', { v: signedTag }));
console.log(fmt('Got: %v%', { v: currentTag }));
}
});
document.getElementById('resetBtn').addEventListener('click', () => {
signedTag = null;
msgBits = [];
flippedIndices = new Set();
document.getElementById('tagBox').classList.add('hidden');
document.getElementById('tamperRow').classList.add('hidden');
document.getElementById('verifyBtn').disabled = true;
document.getElementById('status').textContent = '';
document.getElementById('status').className = 'status';
document.getElementById('msg').value = 'Transfer $100 to Alice';
document.getElementById('key').value = 'my-secret-key';
});
Notice the avalanche effect: changing even one bit in the message produces a completely different tag. An attacker who intercepts the message and tweaks it cannot produce a valid tag without knowing the key. And crucially, HMAC also defeats length-extension attacks that plague plain SHA-256 — you cannot append extra data to a message and compute a valid new tag without the key.
Comments
Loading comments...