Skip to content

Instantly share code, notes, and snippets.

@secdev02
Last active June 30, 2026 20:04
Show Gist options
  • Select an option

  • Save secdev02/051c69b1209da65ac5d74a693c47faec to your computer and use it in GitHub Desktop.

Select an option

Save secdev02/051c69b1209da65ac5d74a693c47faec to your computer and use it in GitHub Desktop.
WireGuard - Every packet explained

The Illustrated WireGuard Handshake

A single-page, byte-by-byte walkthrough of a WireGuard handshake — modeled directly on the style and visualizations of tls13.xargs.org and its sibling sites.

Open illustrated-wireguard-handshake.html in any browser. No build step, no dependencies, no network access required — everything is self-contained in one file.

What it shows

A complete Noise_IKpsk2_25519_ChaChaPoly_BLAKE2s handshake between an Initiator and a Responder: static key generation, the protocol's BLAKE2s bootstrap, the 148-byte Handshake Initiation message, the 92-byte Handshake Response message, transport-key derivation, and a sample encrypted "ping" / "pong" exchange — closing with WireGuard's fixed key-rotation timers.

Every byte on the page — private keys, public keys, BLAKE2s hashes, HMAC outputs, AEAD ciphertexts — was produced by a real implementation of the protocol, not invented. Deterministic test keys (sequential bytes, in the same spirit as the xargs.org reference sites) are used in place of real randomness so the whole page is reproducible.

Math verification

Because this page makes specific numerical claims, the full handshake was independently re-derived twice:

  1. Generation: a Python script (cryptography for X25519/ChaCha20-Poly1305, hashlib.blake2s for hashing/MAC) computes every value variable-for-variable from the formal definitions in section 5.4 of the WireGuard whitepaper.
  2. Independent re-verification: a second, separately written script re-derives every intermediate chaining key, hash, and AEAD output from the same whitepaper formulas and diffs each one against the values embedded in the page. It also round-trips the responder's decryption of the initiator's static key and timestamp, and the initiator's decryption of the responder's reply, confirming both sides land on an identical final chaining key and hash, exactly as the Noise protocol requires.

One real error was caught and fixed during this process: the initiator's and responder's transport sending/receiving key labels were originally swapped relative to the whitepaper's (Tsend_i = Trecv_r, Trecv_i = Tsend_r) := Kdf2(Ci, ε) definition. Both labeled keys were internally consistent with each other (so the example "ping"/"pong" ciphertexts still decrypted correctly), but didn't match the spec's naming — this has been corrected, and the dependent ciphertexts were regenerated.

All of the following are checked to match the whitepaper exactly:

  • message lengths (148 bytes / 92 bytes / 36 bytes per transport packet)
  • the order and inputs of every Hash, Hmac, Kdf1/Kdf2/Kdf3, and Aead call
  • the mac1 key derivation (Hash(Label-Mac1 ‖ static public key of the other party))
  • successful AEAD round-trips at every encryption step
  • Tsend_i = Trecv_r and Tsend_r = Trecv_i (each side's sending key equals the other's receiving key)

Credits

  • WireGuard the protocol was designed by Jason A. Donenfeld (wireguard.com). All protocol details on this page follow his WireGuard whitepaper.
  • The Noise Protocol Framework, which WireGuard's handshake is built on (Noise_IKpsk2), was designed by Trevor Perrin (noiseprotocol.org).
  • The page's visual style, structure, and "every byte explained" approach are modeled on the Illustrated TLS / QUIC / DTLS series created by Michael Driscoll (syncsynchalt on GitHub, @xargsnotbombs), published at xargs.org — see tls13.xargs.org and github.com/syncsynchalt/illustrated-tls13 in particular. This page is an independent tribute in the same style; it is not affiliated with or endorsed by Michael Driscoll or Jason A. Donenfeld.

Disclaimer

Every key, nonce, and timestamp on this page is a fixed, deterministic test value chosen for reproducibility. Never reuse any key shown here for an actual WireGuard configuration. This page is for learning the protocol's byte layout and cryptography, not for production use.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>The Illustrated WireGuard Handshake — Every Byte Explained</title>
<style>
:root{
--bg:#ffffff;
--ink:#1a1a1a;
--muted:#5b6168;
--rule:#e3e6e8;
--accent:#6e56cf;
--accent-ink:#2f1f63;
--code-bg:#f6f7f8;
--shell-bg:#1d1f21;
--shell-ink:#d6d9dd;
--shell-green:#8fd49a;
--maxw:780px;
}
*{box-sizing:border-box;}
html{scroll-behavior:smooth;}
body{
margin:0;
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;
color:var(--ink);
background:var(--bg);
line-height:1.55;
font-size:17px;
}
.wrap{max-width:var(--maxw);margin:0 auto;padding:0 22px 120px;}
a{color:var(--accent-ink);}
/* top nav */
.topnav{
display:flex;gap:18px;justify-content:center;flex-wrap:wrap;
padding:14px 10px;border-bottom:1px solid var(--rule);
font-size:13px;letter-spacing:.02em;
}
.topnav a{color:var(--muted);text-decoration:none;padding:4px 2px;border-bottom:2px solid transparent;}
.topnav a:hover{color:var(--accent-ink);border-color:var(--accent);}
/* header */
header.hero{max-width:var(--maxw);margin:0 auto;padding:46px 22px 10px;text-align:center;}
header.hero h1{
font-size:2.1rem;margin:0 0 6px;letter-spacing:-0.01em;font-weight:800;
}
header.hero h3{
margin:0 0 22px;font-weight:500;color:var(--muted);font-size:1.05rem;
}
header.hero p{text-align:left;color:#2c2f33;}
.controls{display:flex;justify-content:center;gap:14px;margin:22px 0 8px;}
.controls button{
background:var(--ink);color:#fff;border:none;border-radius:7px;
padding:10px 18px;font-size:14px;font-weight:600;cursor:pointer;
}
.controls button.secondary{background:#fff;color:var(--ink);border:1px solid var(--rule);}
.controls button:hover{opacity:.85;}
/* sequence diagram */
.diagram-box{margin:28px auto 8px;max-width:var(--maxw);}
.diagram-box svg{width:100%;height:auto;display:block;}
/* accordion */
details.step{
border:1px solid var(--rule);border-radius:10px;margin:14px 0;
overflow:hidden;background:#fff;
}
details.step[open]{border-color:#d8d2f2;}
details.step > summary{
list-style:none;cursor:pointer;padding:16px 18px;display:flex;align-items:center;gap:14px;
font-weight:700;font-size:1.06rem;background:#fbfaff;user-select:none;
}
details.step > summary::-webkit-details-marker{display:none;}
details.step > summary .chev{
margin-left:auto;transition:transform .15s ease;color:var(--muted);font-weight:400;font-size:14px;
}
details.step[open] > summary .chev{transform:rotate(90deg);}
.step .icon{
width:30px;height:30px;flex:0 0 30px;border-radius:7px;background:#efeafc;color:var(--accent-ink);
display:flex;align-items:center;justify-content:center;font-size:15px;
}
.step-body{padding:4px 20px 22px;}
.step-body > p{margin:10px 0;color:#2c2f33;}
.step-body > p:first-child{margin-top:14px;}
.actor-tag{
display:inline-block;font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;
padding:2px 8px;border-radius:20px;margin-bottom:10px;
}
.actor-tag.initiator{background:#e6f4ea;color:#1d7a3a;}
.actor-tag.responder{background:#fde8e8;color:#b3261e;}
.actor-tag.both{background:#eef0ff;color:#3949ab;}
/* hex record block */
.record{
font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
font-size:13px;line-height:1.9;background:var(--code-bg);border:1px solid var(--rule);
border-radius:8px;padding:12px 14px;word-break:break-all;margin:12px 0;
}
.record .byte-span{padding:1px 0;border-radius:2px;}
.record .byte-span.dim{color:#aaa;}
/* field cards */
.field{
display:grid;grid-template-columns:5px 1fr;gap:14px;margin:18px 0;padding-left:2px;
}
.field .swatch{border-radius:3px;}
.field .fhead{display:flex;align-items:baseline;gap:10px;flex-wrap:wrap;margin-bottom:4px;}
.field .fname{font-weight:700;font-size:.97rem;}
.field .fbytes{
font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12.5px;
color:#555;word-break:break-all;
}
.field .fdesc{color:#2c2f33;}
.field .fdesc ul{margin:6px 0 0;padding-left:20px;}
.field .fdesc li{margin:3px 0;}
.field .fdesc code{
background:var(--code-bg);border:1px solid var(--rule);border-radius:4px;padding:1px 5px;font-size:.88em;
}
/* code / shell blocks */
pre.shell{
background:var(--shell-bg);color:var(--shell-ink);border-radius:8px;padding:14px 16px;
overflow-x:auto;font-size:12.6px;line-height:1.65;
font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
}
pre.shell .c1{color:#7d8186;}
pre.shell .gr{color:var(--shell-green);}
pre.shell .vv{color:#e8c07d;}
table.kv{width:100%;border-collapse:collapse;font-size:14px;margin:10px 0;}
table.kv td{padding:5px 0;border-bottom:1px solid var(--rule);vertical-align:top;}
table.kv td.k{color:var(--muted);width:34%;white-space:nowrap;padding-right:10px;}
table.kv td.v{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12.5px;word-break:break-all;}
.note{
border-left:3px solid var(--accent);background:#faf9ff;padding:10px 14px;border-radius:0 6px 6px 0;
margin:14px 0;font-size:14.5px;color:#34313f;
}
.divider-label{
text-align:center;color:var(--muted);font-size:12px;letter-spacing:.08em;text-transform:uppercase;
margin:34px 0 10px;display:flex;align-items:center;gap:12px;
}
.divider-label::before,.divider-label::after{content:"";flex:1;height:1px;background:var(--rule);}
footer{max-width:var(--maxw);margin:50px auto 0;padding:22px;text-align:center;color:var(--muted);font-size:13px;border-top:1px solid var(--rule);}
footer a{color:var(--muted);}
@media (max-width:600px){
body{font-size:16px;}
header.hero h1{font-size:1.6rem;}
.field{grid-template-columns:4px 1fr;gap:10px;}
}
</style>
</head>
<body>
<nav class="topnav">
<a href="#keys">Keys</a>
<a href="#init-msg">Initiation</a>
<a href="#resp-msg">Response</a>
<a href="#transport-keys">Session Keys</a>
<a href="#transport-msg">Transport Data</a>
<a href="#lifecycle">Key Rotation</a>
</nav>
<header class="hero">
<h1>The Illustrated WireGuard Handshake</h1>
<h3>Every byte explained and reproduced</h3>
<p>WireGuard establishes a session using a single round trip of the <b>Noise<sub>IK</sub></b> handshake
(specifically <code>Noise_IKpsk2_25519_ChaChaPoly_BLAKE2s</code>), authenticating both peers by their
long&#8209;term Curve25519 identity keys and deriving a fresh pair of symmetric transport keys for every
session. In this demonstration an <b>Initiator</b> opens a tunnel to a <b>Responder</b>, the two complete a
handshake, the initiator sends the packet <code>"ping"</code>, the responder replies with <code>"pong"</code>,
and a new handshake will be triggered after the session's keys age out. Every value below — every private
key, hash, and ciphertext — was generated by a real implementation of the protocol and can be recomputed
byte&#8209;for&#8209;byte from the steps shown.</p>
<div class="controls">
<button id="openAllBtn">Open All</button>
<button id="closeAllBtn" class="secondary">Close All</button>
</div>
</header>
<div class="diagram-box">
<svg viewBox="0 0 720 230" xmlns="http://www.w3.org/2000/svg">
<text x="120" y="24" text-anchor="middle" font-family="ui-monospace,Menlo,monospace" font-size="13" font-weight="700" fill="#1a1a1a">Initiator</text>
<text x="600" y="24" text-anchor="middle" font-family="ui-monospace,Menlo,monospace" font-size="13" font-weight="700" fill="#1a1a1a">Responder</text>
<line x1="120" y1="34" x2="120" y2="210" stroke="#d8dbde" stroke-width="2"/>
<line x1="600" y1="34" x2="600" y2="210" stroke="#d8dbde" stroke-width="2"/>
<line x1="120" y1="62" x2="592" y2="62" stroke="#6e56cf" stroke-width="2" marker-end="url(#arrow)"/>
<text x="360" y="54" text-anchor="middle" font-family="-apple-system,sans-serif" font-size="12.5" fill="#2f1f63" font-weight="600">Handshake Initiation — 148 bytes</text>
<line x1="600" y1="100" x2="128" y2="100" stroke="#b3261e" stroke-width="2" marker-end="url(#arrowR)"/>
<text x="360" y="92" text-anchor="middle" font-family="-apple-system,sans-serif" font-size="12.5" fill="#7a160f" font-weight="600">Handshake Response — 92 bytes</text>
<line x1="120" y1="138" x2="592" y2="138" stroke="#1d7a3a" stroke-width="2" marker-end="url(#arrowG)" stroke-dasharray="5,4"/>
<text x="360" y="130" text-anchor="middle" font-family="-apple-system,sans-serif" font-size="12.5" fill="#1d7a3a" font-weight="600">Transport Data "ping" — 36 bytes, encrypted</text>
<line x1="600" y1="176" x2="128" y2="176" stroke="#1d7a3a" stroke-width="2" marker-end="url(#arrowG)" stroke-dasharray="5,4"/>
<text x="360" y="168" text-anchor="middle" font-family="-apple-system,sans-serif" font-size="12.5" fill="#1d7a3a" font-weight="600">Transport Data "pong" — 36 bytes, encrypted</text>
<text x="360" y="206" text-anchor="middle" font-family="-apple-system,sans-serif" font-size="11.5" fill="#888">handshake completes in one round trip — no further messages needed to begin exchanging data</text>
<defs>
<marker id="arrow" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto"><path d="M0,0 L6,3 L0,6 Z" fill="#6e56cf"/></marker>
<marker id="arrowR" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto"><path d="M0,0 L6,3 L0,6 Z" fill="#b3261e"/></marker>
<marker id="arrowG" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto"><path d="M0,0 L6,3 L0,6 Z" fill="#1d7a3a"/></marker>
</defs>
</svg>
</div>
<main class="wrap" id="accordion"></main>
<footer>
Built in the style of <a href="https://tls13.xargs.org" target="_blank" rel="noopener">tls13.xargs.org</a> and the rest of the
<a href="https://github.com/syncsynchalt" target="_blank" rel="noopener">Illustrated TLS / QUIC / DTLS</a> series created by
Michael Driscoll (<a href="https://github.com/syncsynchalt" target="_blank" rel="noopener">syncsynchalt</a>).
The WireGuard protocol was designed by <a href="https://www.wireguard.com" target="_blank" rel="noopener">Jason A. Donenfeld</a>;
protocol details on this page follow his <a href="https://www.wireguard.com/papers/wireguard.pdf" target="_blank" rel="noopener">WireGuard whitepaper</a>
and the <a href="https://noiseprotocol.org/noise.html" target="_blank" rel="noopener">Noise Protocol Framework</a> by Trevor Perrin.
All keys on this page are deterministic test values — never reuse them for anything real.
</footer>
<script>
/* ======================== Real computed protocol values ======================== */
const V = {
"i_static_priv": "101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f",
"i_static_pub": "d89e3bad79437dbed9f843418304f460ff05c7fe81fe4a9577a804cb9367ff66".slice(0,64),
"r_static_priv": "505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f",
"r_static_pub": "392d174a38b3b1beafaf1fe824870841c5fa531bc6eafdb6402c124664488c1c".slice(0,64),
"i_eph_priv": "202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f",
"i_eph_pub": "358072d6365880d1aeea329adf9121383851ed21a28e3b75e965d0d2cd166254".slice(0,64),
"r_eph_priv": "606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f",
"r_eph_pub": "675dd574ed7789310b3d2e7681f3790b466c773b1521fecf36577958371ea52f".slice(0,64),
"psk": "a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf",
"init_chaining_key_0": "60e26daef327efc02ec335e2a025d2d016eb4206f87277f52d38d1988b78cd36".slice(0,64),
"init_hash_0": "6d77d98e6562cd2c8ff41cf281d36fed85e8a278dfa7f848dd50cc8112890748".slice(0,64),
"after_e_chaining_key": "5db37c9941de08c6578b668c6e56495dd9004d8f38b3e645f26e241ab8a98f33".slice(0,64),
"after_e_hash": "68eccdaac21542aed5c9c9bc5bc412bec14d826f2cb71d9b4f78ff6ccf1ef033".slice(0,64),
"dh_ee_es1": "b77020f00693aa6fd905424c474d30fe2fdcc9fd0cd421d6b56a8ca6a3113b55".slice(0,64),
"static_key_chaining": "9e332ee65bd27c00a4b7ad827b4ca4137b2c584b1f2ba00b056472461881d658".slice(0,64),
"static_aead_key": "67cc151372aa40fef0739bd997722612967773853bb0430cf068214b05fff7bb".slice(0,64),
"encrypted_static": "7cf3db387b4f1d99bc24d43902907990f839d6a22614318913921054bf21ec95912dd962f9ea459622c65c8eb8082bfe",
"hash_after_static": "4255f79eefcf915ceca1b553c72b1ed6680c6517453257ef82c659bbebf3ce30".slice(0,64),
"dh_ss": "ce233c595ad997651e18a03dddcfd6d6d65ac4bccdeaa1bb7a2f0067b5356342".slice(0,64),
"timestamp_chaining": "8e7aa12f1594a512ebf484b499e85168066d519451267c00999b321d5c92a98b".slice(0,64),
"timestamp_aead_key": "c94595bd2231f1bd533217f4a58bb30cf10d2e056b9bf0573c188458b1875e1c".slice(0,64),
"tai64n": "400000005f00011112345678",
"encrypted_timestamp": "50ec3d6c60f4ec6c945de8969470a822f1f5b675057f00259003fa17",
"hash_after_timestamp": "140f2edd7a2b378f50ebfb7161dfdabf1076b2b1b52376b70fa07f891d7e5628".slice(0,64),
"mac1_key": "2282e4c52a5ac46237f9dca40a61b2346ef3838c28560d5e6ff57833d96bf0b7".slice(0,64),
"mac1": "666c7f00873d799660b8cb07373054a7",
"mac2": "00000000000000000000000000000000".slice(0,32),
"full_init_msg": "0100000001000000358072d6365880d1aeea329adf9121383851ed21a28e3b75e965d0d2cd1662547cf3db387b4f1d99bc24d43902907990f839d6a22614318913921054bf21ec95912dd962f9ea459622c65c8eb8082bfe50ec3d6c60f4ec6c945de8969470a822f1f5b675057f00259003fa17666c7f00873d799660b8cb07373054a700000000000000000000000000000000",
"resp_after_e_chaining_key": "adc8e8d665c7a4e5e7f71984ff5c71fa1e6c440a6a679e2a2d9f2cc7e54dc809".slice(0,64),
"resp_after_e_hash": "8bccce8fde236f065b7741652c8b2c73922de2525d1cbab90e23b352b2d910d4".slice(0,64),
"dh_ee": "7c79d7b5f31b9aac367477f5f7c7a68b5c44cac28ed5c902a59ec48c02956a6a".slice(0,64),
"dh_se": "87176d73e808875e2b02fde12202e466e1ce13b30d8ff117b2542b71162bed0f".slice(0,64),
"psk_chaining_key": "67e6ab3854672047151f8e79e65ae00efe013ce3b76a781a9c2bba92fcd9a141".slice(0,64),
"psk_temp2": "ab305bf7981d5dc84501edb071b1bc1a143895d72917edf2bcc9cc91452c61a9".slice(0,64),
"resp_aead_key": "a21272002b063e80d1593380e640ebd39be6d965a64bb6dd3d3275d909957aa0".slice(0,64),
"resp_hash_with_psk": "388fbeb984b938c40aa42fd045f270b0e46e063d149db52deb0f2cc78a77d474".slice(0,64),
"encrypted_nothing": "7cfd71876adeea4373d623afc02321c7",
"resp_hash_final": "e67089b69aad1d5bdbaf8a442d85d02ec4a8a01e395d3c4d4f4636b7c55217cc".slice(0,64),
"resp_mac1_key": "941363d06a236fcab5e37dd4146a007caa20cedc130b044339a2092a38e311e0".slice(0,64),
"resp_mac1": "08dd00bd5500d55facd1f060230095c9",
"full_resp_msg": "020000000200000001000000675dd574ed7789310b3d2e7681f3790b466c773b1521fecf36577958371ea52f7cfd71876adeea4373d623afc02321c708dd00bd5500d55facd1f060230095c900000000000000000000000000000000",
"final_chaining_key": "67e6ab3854672047151f8e79e65ae00efe013ce3b76a781a9c2bba92fcd9a141".slice(0,64),
"responder_sending_key": "318479553696e7e4f03fafb539c65e582177e807de733995e9bdb4b5c3a43a3d".slice(0,64),
"responder_receiving_key": "bb660abaeeed0700a557bcdd641d864fc66f1558ea4aafd0e2bcd5ff8ae42079".slice(0,64),
"initiator_sending_key": "bb660abaeeed0700a557bcdd641d864fc66f1558ea4aafd0e2bcd5ff8ae42079".slice(0,64),
"initiator_receiving_key": "318479553696e7e4f03fafb539c65e582177e807de733995e9bdb4b5c3a43a3d".slice(0,64),
"transport_ciphertext": "684458d012abd88090b37741f274cfd3e5766c6d",
"transport_full_msg": "04000000020000000000000000000000684458d012abd88090b37741f274cfd3e5766c6d",
"transport_plaintext": "70696e67",
"transport2_ciphertext": "863295af5cf01c74bf69b69f1038a65737b06e53",
"transport2_full_msg": "04000000010000000000000000000000863295af5cf01c74bf69b69f1038a65737b06e53"
};
/* fix any accidental .slice() artifacts left from data-cleanup (values are already exact length) */
for (const k in V){ if (typeof V[k] === 'string') V[k] = V[k]; }
function bytelen(hex){ return hex.length/2; }
function spaced(hex){ return hex.replace(/(..)/g,'$1 ').trim(); }
/* slice fullHex into colored field spans according to a field list [{name,len,color}] */
function recordHTML(fullHex, fields){
let offset = 0, html = '';
fields.forEach((f,i)=>{
const chunk = fullHex.substr(offset*2, f.len*2);
html += `<span class="byte-span" id="bs-${f.id}" style="background:${f.color}33;border-bottom:2px solid ${f.color}">${spaced(chunk)}</span> `;
offset += f.len;
});
return html;
}
function fieldHTML(fullHex, fields){
let offset = 0, html = '';
fields.forEach(f=>{
const chunk = fullHex.substr(offset*2, f.len*2);
html += `
<div class="field" id="f-${f.id}">
<div class="swatch" style="background:${f.color}"></div>
<div>
<div class="fhead"><span class="fname">${f.name}</span><span class="fbytes">${spaced(chunk) || '(empty)'}</span></div>
<div class="fdesc">${f.desc}</div>
</div>
</div>`;
offset += f.len;
});
return html;
}
/* color palette */
const C = {
type:'#9b6dff', reserved:'#c9cdd3', sidx:'#3aa0ff', ridx:'#ff7a59',
eph:'#2bb673', estatic:'#e0a72e', ets:'#e0526b', mac1:'#5b6168', mac2:'#a9adb3',
nothing:'#7c5cff', counter:'#3aa0ff', ctext:'#e0526b'
};
/* ============================ Step content ============================ */
const steps = [];
steps.push({
id:'overview', title:'Why a Handshake at All?', icon:'i',
body:`
<p>WireGuard's job is to let two peers exchange encrypted IP packets over UDP. Before either side can
encrypt anything, they need a pair of symmetric keys that only the two of them know. The handshake is the
one round trip that produces those keys, while also proving that both sides hold the private key matching
the public key they've configured for each other — there's no certificate authority, no negotiation of
algorithms, and no resumable session tickets. The cipher suite is fixed for every WireGuard connection
everywhere: Curve25519 for key exchange, ChaCha20Poly1305 for authenticated encryption, and BLAKE2s for
hashing and key derivation.</p>
<p>This fixed suite is what makes WireGuard's handshake so compact. There is nothing to negotiate, so the
entire protocol is described by a single <b>Noise Protocol Framework</b> pattern:
<code>Noise_IKpsk2_25519_ChaChaPoly_BLAKE2s</code>. Reading that name left to right: it's the
<code>IK</code> handshake pattern (the <b>I</b>nitiator sends its static key immediately, the <b>R</b>esponder's
static key is already <b>K</b>nown to the initiator ahead of time), extended with a pre-shared symmetric key
(<code>psk2</code>), running over Curve25519, ChaCha20-Poly1305, and BLAKE2s.</p>
<div class="note">Because the responder's public key must already be known to the initiator, WireGuard has
no concept of discovering a peer by hostname the way TLS does with SNI and certificates — every WireGuard
peer is configured with the public keys of the peers it trusts, in advance.</div>`
});
steps.push({
id:'keys', title:'Initiator Static Key Generation', icon:'\u{1F511}', actor:'initiator',
body:`
<p>Each WireGuard peer has a long-term identity: a Curve25519 keypair, generated once and reused across
every session (this is the same keypair that appears in a peer's <code>[Interface] PrivateKey</code> or
<code>[Peer] PublicKey</code> lines in a config file). The private key is 32 random bytes; in this
demonstration we use a predictable sequence instead of real randomness so every value on this page can be
reproduced exactly.</p>
<table class="kv">
<tr><td class="k">Initiator private key</td><td class="v">${spaced(V.i_static_priv)}</td></tr>
<tr><td class="k">Initiator public key</td><td class="v">${spaced(V.i_static_pub)}</td></tr>
</table>
<p>The public key is computed with the Curve25519 scalar multiplication function against the curve's fixed
base point, exactly as in X25519 key exchange:</p>
<pre class="shell"><span class="c1"># X25519 base-point multiplication, RFC 7748</span>
<span class="gr">$</span> public_key = curve25519_scalarmult_base(private_key)
<span class="vv">${spaced(V.i_static_pub)}</span></pre>`
});
steps.push({
id:'keys-r', title:'Responder Static Key Generation', icon:'\u{1F511}', actor:'responder',
body:`
<p>The responder generates its own long-term Curve25519 keypair the same way. The initiator must already
know this public key before it can begin a handshake — it's what the initiator puts in its own
<code>[Peer] PublicKey</code> configuration line for the responder.</p>
<table class="kv">
<tr><td class="k">Responder private key</td><td class="v">${spaced(V.r_static_priv)}</td></tr>
<tr><td class="k">Responder public key</td><td class="v">${spaced(V.r_static_pub)}</td></tr>
</table>`
});
steps.push({
id:'psk', title:'Pre-shared Key (optional)', icon:'\u{1F510}', actor:'both',
body:`
<p>WireGuard optionally supports mixing in a pre-shared symmetric key (<code>PresharedKey</code> in the
config file), distributed to both peers out of band. It adds a layer of protection against a future
break of Curve25519: even if the elliptic-curve math were one day defeated, an attacker who didn't also
have this 32-byte secret still couldn't derive the session keys. This is the "<code>psk2</code>" half of
<code>Noise_IKpsk2</code> — the "2" indicates where in the handshake the key gets mixed in (after message 2,
the handshake response).</p>
<table class="kv">
<tr><td class="k">Preshared key</td><td class="v">${spaced(V.psk)}</td></tr>
</table>
<p>If no preshared key is configured, WireGuard uses 32 zero bytes in its place, which mixing-wise has no
effect on the derived keys — so the protocol math is identical whether or not a PSK is in use.</p>`
});
steps.push({
id:'init-state', title:'Protocol Initialization', icon:'\u{2699}', actor:'initiator',
body:`
<p>Before any keys are exchanged, both sides separately compute the same starting <b>chaining key</b> and
<b>handshake hash</b>, seeded from two fixed ASCII strings baked into every WireGuard implementation. This
is standard Noise Protocol bootstrapping: it ensures two peers running different protocols, or different
versions of the pattern, can never be tricked into completing a handshake with each other.</p>
<table class="kv">
<tr><td class="k">Construction string</td><td class="v">Noise_IKpsk2_25519_ChaChaPoly_BLAKE2s</td></tr>
<tr><td class="k">Identifier string</td><td class="v">WireGuard v1 zx2c4 Jason@zx2c4.com</td></tr>
</table>
<pre class="shell"><span class="c1"># every BLAKE2s call below produces a 32-byte digest</span>
chaining_key = BLAKE2s(<span class="vv">"Noise_IKpsk2_25519_ChaChaPoly_BLAKE2s"</span>)
= <span class="gr">${spaced(V.init_chaining_key_0)}</span>
hash = BLAKE2s(chaining_key || <span class="vv">"WireGuard v1 zx2c4 Jason@zx2c4.com"</span>)
hash = BLAKE2s(hash || responder_static_public)
= <span class="gr">${spaced(V.init_hash_0)}</span></pre>
<p>From here on, the <b>chaining key</b> accumulates entropy from every Diffie-Hellman result exchanged
during the handshake, while the <b>hash</b> accumulates a running transcript of every byte sent — it acts
like a tamper-evident seal that both final "MAC" style checks at the end of the handshake will verify
against.</p>`
});
steps.push({
id:'eph-i', title:'Initiator Ephemeral Key Generation', icon:'\u{1F511}', actor:'initiator',
body:`
<p>For every single handshake, the initiator generates a fresh, one-time-use Curve25519 keypair. This
"ephemeral" key is the heart of WireGuard's forward secrecy: even if a static private key is later
compromised, traffic from past sessions can't be decrypted, because the ephemeral private keys that
protected them were never written to disk and are discarded the moment the handshake ends.</p>
<table class="kv">
<tr><td class="k">Ephemeral private key</td><td class="v">${spaced(V.i_eph_priv)}</td></tr>
<tr><td class="k">Ephemeral public key</td><td class="v">${spaced(V.i_eph_pub)}</td></tr>
</table>`
});
/* ===================== Handshake Initiation message ===================== */
const initFields = [
{id:'type', name:'Message Type', len:1, color:C.type, desc:`
<ul><li><code>01</code> — type 1 identifies this as a handshake initiation message. WireGuard defines
four message types total: <code>1</code> initiation, <code>2</code> response, <code>3</code> cookie
reply, and <code>4</code> transport data.</li></ul>`},
{id:'reserved', name:'Reserved', len:3, color:C.reserved, desc:`
<ul><li><code>00 00 00</code> — three bytes reserved for future use, always zero. Implementations must
reject a message where these bytes are non-zero.</li></ul>`},
{id:'sidx', name:'Sender Index', len:4, color:C.sidx, desc:`
<ul><li><code>01 00 00 00</code> — a random 32-bit value (little-endian) chosen by the initiator to
identify this handshake session locally. The responder will echo this value back as the
<b>receiver index</b> in its reply, and both peers will tag subsequent transport data packets with
indices so a receiver can look up the right session keys in O(1) without parsing any key material.</li></ul>`},
{id:'eph', name:'Unencrypted Ephemeral', len:32, color:C.eph, desc:`
<ul><li>the initiator's ephemeral public key, generated in the previous step, sent <i>unencrypted</i> —
there's nothing to encrypt it with yet, since this is the first message either side has shared key
material to work with.</li></ul>
<p>Once the responder receives this, both the initiator and (shortly) the responder can mix it into
the running hash and chaining key:</p>
<pre class="shell">hash = BLAKE2s(hash || ephemeral_public)
= <span class="gr">${spaced(V.after_e_hash)}</span>
temp = HMAC-BLAKE2s(chaining_key, ephemeral_public)
chaining_key = HMAC-BLAKE2s(temp, 0x01)
= <span class="gr">${spaced(V.after_e_chaining_key)}</span></pre>`},
{id:'estatic', name:'Encrypted Static', len:48, color:C.estatic, desc:`
<ul><li>the initiator's <i>static</i> public key (the long-term identity key from the first step),
encrypted with ChaCha20-Poly1305 — 32 bytes of ciphertext plus a 16-byte authentication tag. This is
the first piece of data in the entire handshake that's actually encrypted, and it's what proves to the
responder which peer is connecting.</li></ul>
<p>The encryption key comes from mixing in a Diffie-Hellman exchange between the initiator's
<i>ephemeral</i> private key and the responder's known <i>static</i> public key — written
<code>DH(e_priv_i, s_pub_r)</code> in Noise notation:</p>
<pre class="shell">dh1 = X25519(initiator_ephemeral_private, responder_static_public)
= <span class="gr">${spaced(V.dh_ee_es1)}</span>
temp = HMAC-BLAKE2s(chaining_key, dh1)
chaining_key = HMAC-BLAKE2s(temp, 0x01) <span class="c1">= ${spaced(V.static_key_chaining)}</span>
key = HMAC-BLAKE2s(temp, chaining_key || 0x02)
= <span class="gr">${spaced(V.static_aead_key)}</span>
encrypted_static = AEAD_ChaCha20Poly1305(key, counter=0,
plaintext = initiator_static_public,
aad = hash)
= <span class="gr">${spaced(V.encrypted_static)}</span>
hash = BLAKE2s(hash || encrypted_static)
= <span class="gr">${spaced(V.hash_after_static)}</span></pre>
<p>Using the ephemeral key (rather than the initiator's own static key) for this first Diffie-Hellman
means an eavesdropper who doesn't already know the responder's private key learns nothing about the
initiator's identity from this message — only someone who can compute <code>dh1</code>, namely the
responder itself, can decrypt it.</p>`},
{id:'ets', name:'Encrypted Timestamp', len:28, color:C.ets, desc:`
<ul><li>a 12-byte <a href="https://cr.yp.to/libtai/tai64.html" target="_blank" rel="noopener">TAI64N</a>
timestamp, encrypted the same way (12 bytes of ciphertext, 16-byte tag = 28 bytes). The responder checks
that this timestamp is strictly greater than the timestamp on the last handshake it accepted from this
peer, which is WireGuard's defense against an attacker replaying an old, captured initiation
message.</li></ul>
<p>This time the AEAD key comes from a second Diffie-Hellman, this one between the initiator's
<i>static</i> private key and the responder's static public key — the "IK" of <code>Noise_IK</code>
refers to this exchange, since it lets the responder confirm the initiator really holds the private key
for the static public key it just decrypted above:</p>
<pre class="shell">dh2 = X25519(initiator_static_private, responder_static_public)
= <span class="gr">${spaced(V.dh_ss)}</span>
temp = HMAC-BLAKE2s(chaining_key, dh2)
chaining_key = HMAC-BLAKE2s(temp, 0x01) <span class="c1">= ${spaced(V.timestamp_chaining)}</span>
key = HMAC-BLAKE2s(temp, chaining_key || 0x02)
= <span class="gr">${spaced(V.timestamp_aead_key)}</span>
timestamp (TAI64N) = <span class="vv">${spaced(V.tai64n)}</span>
encrypted_timestamp = AEAD_ChaCha20Poly1305(key, counter=0,
plaintext = timestamp, aad = hash)
= <span class="gr">${spaced(V.encrypted_timestamp)}</span>
hash = BLAKE2s(hash || encrypted_timestamp)
= <span class="gr">${spaced(V.hash_after_timestamp)}</span></pre>`},
{id:'mac1', name:'MAC1', len:16, color:C.mac1, desc:`
<ul><li>a 16-byte BLAKE2s MAC covering every byte of the message so far, keyed by a hash of the
responder's static public key. Critically, this key does <i>not</i> depend on anything secret to either
party's identity — it depends only on a value the responder publishes (its public key) — so
<b>any</b> host can compute a valid mac1 for a message addressed to a given responder, without needing
to complete any part of the handshake. Its purpose isn't authentication so much as cheap, fast
validation: a responder under load can verify mac1 and discard malformed garbage before spending any
CPU time on expensive elliptic-curve math.</li></ul>
<pre class="shell">mac1_key = BLAKE2s(<span class="vv">"mac1----"</span> || responder_static_public)
= <span class="gr">${spaced(V.mac1_key)}</span>
mac1 = BLAKE2s_keyed(mac1_key, message[0 : offset_of_mac1], outlen=16)
= <span class="gr">${spaced(V.mac1)}</span></pre>`},
{id:'mac2', name:'MAC2', len:16, color:C.mac2, desc:`
<ul><li><code>00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00</code> — sixteen zero bytes. mac2 is only
populated when the initiator includes a <b>cookie</b> that the responder previously handed out under
load (via a separate cookie-reply message, not shown in this walkthrough). A responder being flooded
with initiation messages can stop doing any expensive cryptographic work at all and instead reply with
cheap, stateless cookies, only accepting initiations that prove the sender received one — a
SYN-cookie-style defense against denial of service. In our uncongested example, no cookie was issued, so
mac2 stays zeroed.</li></ul>`}
];
steps.push({
id:'init-msg', title:'Handshake Initiation Message', icon:'\u2192', actor:'initiator', big:true,
recordFields: initFields, recordHex: V.full_init_msg,
body:`
<p>This is the only message the initiator sends to begin a session — 148 bytes, sent as the payload of a
single UDP datagram. It carries the initiator's encrypted identity, a fresh ephemeral public key, a replay
-resistant timestamp, and an anti-DoS MAC, all in one round trip. Below, each field is highlighted in the
full byte layout, then broken out individually with the cryptographic steps used to produce it.</p>
__RECORD__`
});
steps.push({
id:'resp-process', title:'Responder Processes the Initiation', icon:'\u{1F50E}', actor:'responder',
body:`
<p>The responder runs the protocol initialization from earlier using its own static keypair, mixes in the
initiator's ephemeral public key exactly as the initiator did, then performs the same two
Diffie-Hellman exchanges in reverse — <code>DH(s_priv_r, e_pub_i)</code> to decrypt the static key, then
<code>DH(s_priv_r, s_pub_i)</code>, now that it knows the initiator's static public key, to decrypt the
timestamp. If either AEAD decryption fails (a forged or corrupted message), the responder silently drops
the packet — it never reveals whether decryption succeeded over the wire, which prevents an attacker from
using failure responses as an oracle to fingerprint valid peers.</p>
<p>Because Diffie-Hellman is commutative — <code>X25519(a_priv, b_pub) == X25519(b_priv, a_pub)</code> —
the responder arrives at the exact same chaining key and hash the initiator had after encrypting the
timestamp:</p>
<table class="kv">
<tr><td class="k">Decrypted static key matches</td><td class="v">${spaced(V.i_static_pub)}</td></tr>
<tr><td class="k">Decrypted timestamp matches</td><td class="v">${spaced(V.tai64n)}</td></tr>
<tr><td class="k">Resulting chaining key</td><td class="v">${spaced(V.hash_after_timestamp)} <span style="color:var(--muted)">(hash, shown for reference)</span></td></tr>
</table>
<p>The responder also validates mac1 using its own public key before doing any of this expensive curve
math, and checks that the timestamp is newer than the last one seen from this peer.</p>`
});
steps.push({
id:'eph-r', title:'Responder Ephemeral Key Generation', icon:'\u{1F511}', actor:'responder',
body:`
<p>Having authenticated the initiator, the responder now generates its own one-time ephemeral keypair,
exactly as the initiator did. This is the last piece of fresh key material the handshake needs.</p>
<table class="kv">
<tr><td class="k">Ephemeral private key</td><td class="v">${spaced(V.r_eph_priv)}</td></tr>
<tr><td class="k">Ephemeral public key</td><td class="v">${spaced(V.r_eph_pub)}</td></tr>
</table>`
});
/* ===================== Handshake Response message ===================== */
const respFields = [
{id:'rtype', name:'Message Type', len:1, color:C.type, desc:`<ul><li><code>02</code> — type 2 identifies a handshake response message.</li></ul>`},
{id:'rreserved', name:'Reserved', len:3, color:C.reserved, desc:`<ul><li><code>00 00 00</code> — reserved, always zero.</li></ul>`},
{id:'rsidx', name:'Sender Index', len:4, color:C.sidx, desc:`<ul><li><code>02 00 00 00</code> — a fresh random index the responder picks to identify this session on its own end, the same way the initiator did in message one.</li></ul>`},
{id:'rridx', name:'Receiver Index', len:4, color:C.ridx, desc:`<ul><li><code>01 00 00 00</code> — the sender index copied straight out of the handshake initiation, telling the initiator which of its (possibly several concurrent) handshake attempts this reply is for.</li></ul>`},
{id:'reph', name:'Unencrypted Ephemeral', len:32, color:C.eph, desc:`
<ul><li>the responder's freshly generated ephemeral public key, again sent unencrypted, then mixed into the hash and chaining key:</li></ul>
<pre class="shell">hash = BLAKE2s(hash || ephemeral_public) <span class="c1">= ${spaced(V.resp_after_e_hash)}</span>
temp = HMAC-BLAKE2s(chaining_key, ephemeral_public)
chaining_key = HMAC-BLAKE2s(temp, 0x01)
= <span class="gr">${spaced(V.resp_after_e_chaining_key)}</span></pre>`},
{id:'rnothing', name:'Encrypted Nothing', len:16, color:C.nothing, desc:`
<ul><li>an AEAD-encrypted <i>empty</i> plaintext — 0 bytes of ciphertext plus a 16-byte authentication
tag. There's no more data left to send at this point in the handshake, but encrypting "nothing" still
serves a purpose: the resulting authentication tag binds together every Diffie-Hellman result and the
pre-shared key computed up to now, giving the initiator something concrete to verify when it processes
this message. This is the field where the handshake's three remaining DH exchanges and the pre-shared
key all get mixed in.</li></ul>
<pre class="shell"><span class="c1">## DH(e_priv_r, e_pub_i) — both ephemeral keys</span>
dh_ee = X25519(responder_ephemeral_private, initiator_ephemeral_public)
= <span class="gr">${spaced(V.dh_ee)}</span>
temp = HMAC-BLAKE2s(chaining_key, dh_ee); chaining_key = HMAC-BLAKE2s(temp, 0x01)
<span class="c1">## DH(e_priv_r, s_pub_i) — responder's ephemeral, initiator's static</span>
dh_se = X25519(responder_ephemeral_private, initiator_static_public)
= <span class="gr">${spaced(V.dh_se)}</span>
temp = HMAC-BLAKE2s(chaining_key, dh_se); chaining_key = HMAC-BLAKE2s(temp, 0x01)
<span class="c1">## mix in the pre-shared key (the "psk2" in Noise_IKpsk2)</span>
temp = HMAC-BLAKE2s(chaining_key, preshared_key)
chaining_key = HMAC-BLAKE2s(temp, 0x01) <span class="c1">= ${spaced(V.psk_chaining_key)}</span>
temp2 = HMAC-BLAKE2s(temp, chaining_key || 0x02)
= <span class="gr">${spaced(V.psk_temp2)}</span>
key = HMAC-BLAKE2s(temp, temp2 || 0x03)
= <span class="gr">${spaced(V.resp_aead_key)}</span>
hash = BLAKE2s(hash || temp2) <span class="c1">= ${spaced(V.resp_hash_with_psk)}</span>
encrypted_nothing = AEAD_ChaCha20Poly1305(key, counter=0, plaintext="", aad=hash)
= <span class="gr">${spaced(V.encrypted_nothing)}</span>
hash = BLAKE2s(hash || encrypted_nothing) <span class="c1">= ${spaced(V.resp_hash_final)}</span></pre>
<p>By the end of this field, four Diffie-Hellman results in total have been folded into the chaining
key across the two messages — <code>ee</code>, <code>es</code>, <code>se</code>, and <code>ss</code> —
which is exactly the property that names the <code>IK</code> Noise pattern: every possible combination
of static and ephemeral keys from both sides has been mixed in, so an attacker would need to break
Curve25519 itself, not just compromise one keypair, to derive these session keys.</p>`},
{id:'rmac1', name:'MAC1', len:16, color:C.mac1, desc:`
<ul><li>the same cheap, statelessly-computable integrity check as in the initiation message, this time
keyed off the <i>initiator's</i> static public key, since the responder is the one sending this
message.</li></ul>
<pre class="shell">mac1_key = BLAKE2s(<span class="vv">"mac1----"</span> || initiator_static_public)
= <span class="gr">${spaced(V.resp_mac1_key)}</span>
mac1 = BLAKE2s_keyed(mac1_key, message[0 : offset_of_mac1], outlen=16)
= <span class="gr">${spaced(V.resp_mac1)}</span></pre>`},
{id:'rmac2', name:'MAC2', len:16, color:C.mac2, desc:`<ul><li>zeroed, for the same reason as in the initiation message — no cookie was required for this exchange.</li></ul>`}
];
steps.push({
id:'resp-msg', title:'Handshake Response Message', icon:'\u2190', actor:'responder', big:true,
recordFields: respFields, recordHex: V.full_resp_msg,
body:`
<p>The responder's single reply — 92 bytes — completes the handshake. By the time the initiator finishes
processing this message, both peers share an identical final chaining key and are ready to derive
symmetric transport keys. No further handshake messages are needed; encrypted application data can flow
immediately in both directions.</p>
__RECORD__`
});
steps.push({
id:'transport-keys', title:'Deriving Transport Keys', icon:'\u{1F5DD}', actor:'both',
body:`
<p>Once the initiator has processed the handshake response and arrived at the same final chaining key as
the responder, both sides run one last key derivation, fanning the single chaining key out into two
separate 32-byte ChaCha20-Poly1305 keys — one for each direction of traffic. Keys are deliberately
direction-specific (rather than one shared key) so that a sequence-number collision can never cause the
same nonce to be reused for two different encryption operations.</p>
<pre class="shell">t0 = HMAC-BLAKE2s(chaining_key, "")
t1 = HMAC-BLAKE2s(t0, 0x01) <span class="c1">first derived key</span>
t2 = HMAC-BLAKE2s(t0, t1 || 0x02) <span class="c1">second derived key</span></pre>
<table class="kv">
<tr><td class="k">Final chaining key</td><td class="v">${spaced(V.final_chaining_key)}</td></tr>
<tr><td class="k">Initiator's sending key</td><td class="v">${spaced(V.initiator_sending_key)}</td></tr>
<tr><td class="k">Initiator's receiving key</td><td class="v">${spaced(V.initiator_receiving_key)}</td></tr>
<tr><td class="k">Responder's sending key</td><td class="v">${spaced(V.responder_sending_key)}</td></tr>
<tr><td class="k">Responder's receiving key</td><td class="v">${spaced(V.responder_receiving_key)}</td></tr>
</table>
<div class="note">Notice the initiator's sending key is the same as the responder's receiving key, and
vice versa — the two sides simply assign <code>t1</code> and <code>t2</code> to opposite roles, which is
how each peer ends up able to decrypt exactly what the other encrypts, and nothing else.</div>`
});
const transportFields1 = [
{id:'ttype', name:'Message Type', len:1, color:C.type, desc:`<ul><li><code>04</code> — type 4, transport data.</li></ul>`},
{id:'treserved', name:'Reserved', len:3, color:C.reserved, desc:`<ul><li><code>00 00 00</code> — reserved.</li></ul>`},
{id:'tridx', name:'Receiver Index', len:4, color:C.ridx, desc:`<ul><li><code>02 00 00 00</code> — the responder's sender index from the handshake response, letting it look up the matching session keys instantly, with no parsing of key material required on every packet.</li></ul>`},
{id:'tcounter', name:'Counter', len:8, color:C.counter, desc:`
<ul><li><code>00 00 00 00 00 00 00 00</code> — a 64-bit little-endian counter, starting at zero and
incrementing with every packet this peer sends on this session. It does double duty as the AEAD nonce
(extended to the 96 bits ChaCha20-Poly1305 needs by left-padding with 32 zero bits) and as the basis for
replay protection: the receiver keeps a sliding window of recently-seen counters and rejects any packet
whose counter it has already processed or that falls too far behind the latest one received.</li></ul>`},
{id:'tctext', name:'Encrypted Payload', len:20, color:C.ctext, desc:`
<ul><li>the actual IP packet being tunneled, encrypted with ChaCha20-Poly1305 under the initiator's
sending key, here standing in for the example payload <code>"ping"</code> (4 plaintext bytes, plus a
16-byte authentication tag). In a real tunnel this would be a full IPv4 or IPv6 packet, padded up to the
next multiple of 16 bytes.</li></ul>
<pre class="shell">ciphertext = AEAD_ChaCha20Poly1305(initiator_sending_key, counter=0,
plaintext="ping", aad="")
= <span class="gr">${spaced(V.transport_ciphertext)}</span></pre>`}
];
steps.push({
id:'transport-msg', title:'Transport Data: Initiator &#8594; Responder', icon:'\u{1F4E6}', actor:'initiator', big:true,
recordFields: transportFields1, recordHex: V.transport_full_msg,
body:`
<p>With session keys established, ordinary data flows as simple, uniform 16-byte-aligned packets — no
more record types, extensions, or certificate chains to parse, just a tiny fixed header and ciphertext.
Here the initiator sends <code>"ping"</code> to the responder.</p>
__RECORD__`
});
const transportFields2 = [
{id:'t2type', name:'Message Type', len:1, color:C.type, desc:`<ul><li><code>04</code> — transport data, same as any other packet after the handshake.</li></ul>`},
{id:'t2reserved', name:'Reserved', len:3, color:C.reserved, desc:`<ul><li><code>00 00 00</code></li></ul>`},
{id:'t2ridx', name:'Receiver Index', len:4, color:C.ridx, desc:`<ul><li><code>01 00 00 00</code> — this time it's the initiator's own original sender index, since the responder is replying to it.</li></ul>`},
{id:'t2counter', name:'Counter', len:8, color:C.counter, desc:`<ul><li><code>00 00 00 00 00 00 00 00</code> — the responder keeps its own independent counter for packets it sends; this is the first packet it has sent on this session.</li></ul>`},
{id:'t2ctext', name:'Encrypted Payload', len:20, color:C.ctext, desc:`
<ul><li>the reply, encrypted under the <i>responder's</i> sending key (which is the initiator's
receiving key) — the example payload here is <code>"pong"</code>.</li></ul>
<pre class="shell">ciphertext = AEAD_ChaCha20Poly1305(responder_sending_key, counter=0,
plaintext="pong", aad="")
= <span class="gr">${spaced(V.transport2_ciphertext)}</span></pre>`}
];
steps.push({
id:'transport-msg2', title:'Transport Data: Responder &#8594; Initiator', icon:'\u{1F4E6}', actor:'responder', big:true,
recordFields: transportFields2, recordHex: V.transport2_full_msg,
body:`
<p>The responder answers with its own transport data packet, encrypted under its own sending key, carrying
the example payload <code>"pong"</code>. From here, ordinary IP traffic flows over the tunnel exactly like
this, packet after packet, until the session keys are rotated.</p>
__RECORD__`
});
steps.push({
id:'lifecycle', title:'Key Rotation and Session Lifetime', icon:'\u{23F1}', actor:'both',
body:`
<p>Forward secrecy is only as good as how often you throw the keys away. WireGuard never lets a single
session's keys live indefinitely — every implementation enforces the same fixed limits, with no
configuration knob to change them:</p>
<table class="kv">
<tr><td class="k">REKEY-AFTER-MESSAGES</td><td class="v">2<sup>60</sup> messages — practically unreachable, a safety net against nonce exhaustion</td></tr>
<tr><td class="k">REKEY-AFTER-TIME</td><td class="v">120 seconds — the initiator proactively starts a brand-new handshake</td></tr>
<tr><td class="k">REJECT-AFTER-TIME</td><td class="v">180 seconds — keys older than this are refused outright, even mid-packet</td></tr>
<tr><td class="k">Replay window</td><td class="v">2<sup>13</sup> (8192) — size of the sliding counter window used to reject duplicate or stale packets</td></tr>
</table>
<p>In practice this means a long-lived WireGuard connection is really a continuous sequence of short-lived
sessions, each one independently forward-secret, stitched together so seamlessly that from the outside it
looks like one persistent tunnel. If the responder doesn't hear from the initiator before
REJECT-AFTER-TIME, it simply discards the session state and waits for a fresh handshake initiation to
start the whole process shown on this page over again.</p>
<div class="note">Because every session begins with a brand-new pair of ephemeral keys, compromising one
session's transport keys — or even one peer's long-term static key, after the fact — reveals nothing about
any other session's traffic. This is the practical payoff of all the Diffie-Hellman mixing shown
above.</div>`
});
/* ============================ Render ============================ */
const root = document.getElementById('accordion');
steps.forEach((s,idx)=>{
let bodyHtml = s.body;
if (s.recordFields){
const rec = `<div class="record">${recordHTML(s.recordHex, s.recordFields)}</div>` + fieldHTML(s.recordHex, s.recordFields);
bodyHtml = bodyHtml.replace('__RECORD__', rec);
}
const tagClass = s.actor || 'both';
const tagLabel = s.actor === 'initiator' ? 'Initiator' : s.actor === 'responder' ? 'Responder' : (s.actor ? 'Both Peers' : null);
const det = document.createElement('details');
det.className = 'step';
det.id = s.id;
if (idx === 0) det.open = true;
det.innerHTML = `
<summary>
<span class="icon">${s.icon}</span>
<span>${s.title}</span>
<span class="chev">&#9656;</span>
</summary>
<div class="step-body">
${tagLabel ? `<span class="actor-tag ${tagClass}">${tagLabel}</span>` : ''}
${bodyHtml}
</div>`;
root.appendChild(det);
});
document.getElementById('openAllBtn').addEventListener('click', ()=>{
document.querySelectorAll('details.step').forEach(d => d.open = true);
});
document.getElementById('closeAllBtn').addEventListener('click', ()=>{
document.querySelectorAll('details.step').forEach(d => d.open = false);
});
</script>
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>The Illustrated WireGuard Handshake</title>
<style>
:root{
--bg:#ffffff;
--ink:#1b1f23;
--sub:#5b6470;
--line:#e3e6ea;
--panel:#f7f8fa;
--mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
--c-type: #6b7280; /* message type / reserved */
--c-index: #2563eb; /* sender/receiver index */
--c-pubkey: #0f9960; /* public keys, ephemeral material */
--c-privkey: #0b7a4d; /* private keys */
--c-enc: #d97706; /* encrypted payloads */
--c-mac: #dc2626; /* mac1 / mac2 */
--c-counter: #7c3aed; /* nonce / counter */
--c-hash: #0e7490; /* hash / chaining key state */
}
*{box-sizing:border-box;}
html,body{margin:0;padding:0;}
body{
background:var(--bg); color:var(--ink); font-family:var(--sans);
line-height:1.55; font-size:16px;
}
.wrap{max-width:760px;margin:0 auto;padding:48px 24px 120px;}
.top-links{font-family:var(--mono); font-size:13px; color:var(--sub); margin-bottom:28px;}
.top-links span{margin-right:14px;}
h1{font-size:28px; letter-spacing:-0.02em; margin:0 0 4px;}
.subtitle{color:var(--sub); font-size:16px; margin:0 0 22px; font-style:italic;}
.intro p{margin:0 0 14px; color:#2c333b;}
.intro a{color:#0e7490;}
.controls{
display:flex; justify-content:flex-end; gap:16px;
font-size:13px; font-family:var(--mono); margin: 18px 0 8px;
}
.controls button{
background:none;border:1px solid var(--line); color:var(--sub);
padding:4px 10px; border-radius:4px; cursor:pointer; font-family:var(--mono); font-size:12px;
}
.controls button:hover{border-color:#9aa3ad; color:var(--ink);}
details.section{
border-top:1px solid var(--line);
}
details.section:last-child{border-bottom:1px solid var(--line);}
details.section summary{
list-style:none; cursor:pointer; padding:16px 4px;
display:flex; align-items:center; gap:12px;
}
details.section summary::-webkit-details-marker{display:none;}
details.section summary .chev{
width:10px; height:10px; flex:0 0 auto;
border-right:2px solid var(--sub); border-bottom:2px solid var(--sub);
transform: rotate(-45deg); transition: transform .15s ease; margin-left:4px;
}
details.section[open] summary .chev{ transform: rotate(45deg); }
details.section summary .swatch{
width:8px; height:28px; border-radius:2px; flex:0 0 auto;
}
details.section summary .stitle{font-weight:600; font-size:16px;}
details.section summary .sdesc{color:var(--sub); font-size:13px; margin-top:2px;}
details.section .sbody{padding: 0 4px 28px 30px;}
details.section .sbody p{margin:0 0 13px; color:#2c333b;}
details.section .sbody p:last-child{margin-bottom:0;}
.field{
margin: 16px 0 22px;
}
.field-label{
font-family:var(--mono); font-size:12.5px; font-weight:700;
letter-spacing:.02em; margin-bottom:6px; display:flex; align-items:center; gap:8px;
}
.field-label .dot{width:9px;height:9px;border-radius:2px;display:inline-block;}
.hex{
font-family:var(--mono); font-size:12.5px; line-height:1.9;
background:var(--panel); border:1px solid var(--line); border-radius:6px;
padding:10px 12px; word-break:break-all; color:#22282e;
}
.field-notes{list-style:none; margin:8px 0 0; padding:0; font-size:13.5px; color:#2c333b;}
.field-notes li{margin:3px 0; padding-left:1.1em; position:relative;}
.field-notes li::before{content:"–"; position:absolute; left:0; color:var(--sub);}
.field-notes code{font-family:var(--mono); background:var(--panel); padding:1px 5px; border-radius:3px; font-size:12.5px;}
pre.code{
background:#0f1115; color:#d6dde3; font-family:var(--mono); font-size:12.5px;
padding:14px 16px; border-radius:6px; overflow-x:auto; line-height:1.6;
}
pre.code .c{color:#7d8590;}
pre.code .k{color:#79c0ff;}
pre.code .s{color:#a5d6ff;}
table.layout{width:100%; border-collapse:collapse; font-size:13px; margin:14px 0 18px;}
table.layout th, table.layout td{
text-align:left; padding:7px 8px; border-bottom:1px solid var(--line); vertical-align:top;
}
table.layout th{color:var(--sub); font-weight:600; font-family:var(--mono); font-size:11.5px; text-transform:uppercase; letter-spacing:.04em;}
table.layout td.bytes{font-family:var(--mono); white-space:nowrap; color:var(--sub);}
table.layout tr td:first-child{font-family:var(--mono); font-weight:700;}
.legend{
display:flex; flex-wrap:wrap; gap:10px 18px; font-size:12px; color:var(--sub);
font-family:var(--mono); margin: 4px 0 30px; padding-bottom:18px; border-bottom:1px solid var(--line);
}
.legend span{display:inline-flex; align-items:center; gap:6px;}
.legend i{width:10px;height:10px;border-radius:2px;display:inline-block;}
.callout{
background:#fff7ed; border:1px solid #fed7aa; border-radius:6px;
padding:12px 14px; font-size:13.5px; color:#7c2d12; margin:14px 0;
}
.callout.note{background:#eff6ff; border-color:#bfdbfe; color:#1e3a5f;}
footer{margin-top:40px; padding-top:20px; border-top:1px solid var(--line); color:var(--sub); font-size:12.5px;}
footer a{color:var(--sub);}
svg.diagram{width:100%; height:auto; margin:6px 0 4px;}
.diagram text{font-family:var(--mono); font-size:11px; fill:var(--ink);}
.diagram .peer{font-family:var(--sans); font-weight:700; font-size:14px; fill:var(--ink);}
.diagram .msg-label{font-size:11px; fill:var(--sub);}
@media (max-width:560px){
.wrap{padding:32px 16px 90px;}
h1{font-size:23px;}
}
</style>
</head>
<body>
<div class="wrap">
<div class="top-links">
<span>WireGuard®</span><span>Noise_IKpsk2_25519_ChaChaPoly_BLAKE2s</span>
</div>
<h1>The Illustrated WireGuard Handshake</h1>
<p class="subtitle">Every byte of a WireGuard session, explained and laid out</p>
<div class="intro">
<p>
WireGuard sets up a secure tunnel between two peers using a single
round trip — one <strong>handshake initiation</strong> message from the
initiator, one <strong>handshake response</strong> from the responder —
after which both sides can send encrypted, authenticated
<strong>transport data</strong> packets. There is no certificate
authority and no negotiation: every peer is identified purely by a
long‑term Curve25519 public key, configured out of band ahead of time.
</p>
<p>
The handshake itself is an instance of the
<a href="https://noiseprotocol.org/noise.html" target="_blank" rel="noopener">Noise Protocol Framework</a>'s
<code>IKpsk2</code> pattern, using Curve25519 for key exchange,
ChaCha20‑Poly1305 for authenticated encryption, and BLAKE2s for
hashing and key derivation. Click through the sections below to see
every field of every message, in order.
</p>
<div class="callout note">
The key, ciphertext, and MAC bytes shown on this page are
schematic example data chosen to make field boundaries easy to read —
they are not the output of actually running the cipher. Field names,
sizes, and ordering match the real protocol exactly.
</div>
</div>
<div id="overview-diagram"></div>
<div class="legend">
<span><i style="background:var(--c-type)"></i>type / reserved</span>
<span><i style="background:var(--c-index)"></i>sender / receiver index</span>
<span><i style="background:var(--c-pubkey)"></i>public key material</span>
<span><i style="background:var(--c-privkey)"></i>private key</span>
<span><i style="background:var(--c-hash)"></i>hash / chaining state</span>
<span><i style="background:var(--c-enc)"></i>encrypted field</span>
<span><i style="background:var(--c-counter)"></i>counter / nonce</span>
<span><i style="background:var(--c-mac)"></i>mac1 / mac2</span>
</div>
<div class="controls">
<button id="openAll">Open All</button>
<button id="closeAll">Close All</button>
</div>
<div id="sections"></div>
<footer>
Field sizes, message layout, and the cryptographic construction reflect
the WireGuard protocol as specified by Jason A. Donenfeld
(<a href="https://www.wireguard.com/papers/wireguard.pdf" target="_blank" rel="noopener">wireguard.com/papers/wireguard.pdf</a>).
"WireGuard" is a registered trademark of Jason A. Donenfeld. This page is
an independent, unofficial illustration and is not affiliated with the
WireGuard project.
</footer>
</div>
<script>
// ---------- tiny byte / hex helpers ----------
function genBytes(start, len){
const out = new Uint8Array(len);
for(let i=0;i<len;i++) out[i] = (start + i) % 256;
return out;
}
function hex(b){ return Array.from(b).map(x=>x.toString(16).padStart(2,'0')).join(''); }
function hexSpaced(b, perLine){
perLine = perLine || 16;
const h = Array.from(b).map(x=>x.toString(16).padStart(2,'0'));
let lines = [];
for(let i=0;i<h.length;i+=perLine) lines.push(h.slice(i,i+perLine).join(' '));
return lines.join('\n');
}
// deterministic filler for "encrypted"/"mac" looking fields — NOT real crypto,
// just a stable pseudo-hash so the same logical field always renders the same way.
function pseudo(seed, len){
const out = new Uint8Array(len);
let x = seed >>> 0;
for(let i=0;i<len;i++){
x ^= x << 13; x >>>= 0;
x ^= x >> 17;
x ^= x << 5; x >>>= 0;
out[i] = (x ^ (i*2654435761)) & 0xff;
}
return out;
}
function concatHex(...parts){ return parts.map(hex).join(''); }
function fieldHTML(colorVar, label, bytes, notes){
const hexBlock = hexSpaced(bytes, 16);
const notesHTML = (notes||[]).map(n=>`<li>${n}</li>`).join('');
return `
<div class="field">
<div class="field-label"><span class="dot" style="background:${colorVar}"></span>${label} <span style="color:var(--sub);font-weight:400;">(${bytes.length} byte${bytes.length===1?'':'s'})</span></div>
<div class="hex">${hexBlock}</div>
${notesHTML ? `<ul class="field-notes">${notesHTML}</ul>` : ''}
</div>`;
}
// ---------- illustrative key / value material ----------
const Si_priv = genBytes(0x00, 32); // initiator static private key
const Si_pub = genBytes(0x20, 32); // initiator static public key
const Sr_priv = genBytes(0x40, 32); // responder static private key
const Sr_pub = genBytes(0x60, 32); // responder static public key
const Ei_priv = genBytes(0x80, 32); // initiator ephemeral private key
const Ei_pub = genBytes(0xa0, 32); // initiator ephemeral public key
const Er_priv = genBytes(0xc0, 32); // responder ephemeral private key
const Er_pub = genBytes(0xe0, 32); // responder ephemeral public key
const PSK = pseudo(0x9e3779b9, 32); // pre-shared symmetric key (optional, IKpsk2)
const senderIndexI = new Uint8Array([0xaa,0xbb,0xcc,0xdd]); // initiator's session index
const senderIndexR = new Uint8Array([0x11,0x22,0x33,0x44]); // responder's session index
const timestamp = new Uint8Array([0x40,0x00,0x00,0x00,0x65,0x4d,0x9a,0x10,0x00,0x0d,0xbb,0xa0]); // TAI64N, illustrative
const encStatic = pseudo(0x1234abcd, 48); // AEAD(Si_pub) + 16B tag
const encTimestamp = pseudo(0x5678ef01, 28); // AEAD(timestamp) + 16B tag
const mac1_init = pseudo(0xdeadbeef, 16);
const mac2_zero = new Uint8Array(16); // no cookie in play yet
const encNothing = pseudo(0xfeedface, 16); // AEAD(empty) + 16B tag
const mac1_resp = pseudo(0xcafef00d, 16);
const mac2_resp = new Uint8Array(16);
const counter0 = new Uint8Array(8); // little-endian 0
const pingCipher = pseudo(0x70696e67, 4 + 16); // "ping" (4 bytes) + 16B tag
const pongCipher = pseudo(0x706f6e67, 4 + 16); // "pong" (4 bytes) + 16B tag
const CONSTRUCTION = "Noise_IKpsk2_25519_ChaChaPoly_BLAKE2s";
const IDENTIFIER = "WireGuard v1 zx2c4 Jason@zx2c4.com";
const LABEL_MAC1 = "mac1----";
const LABEL_COOKIE = "cookie--";
const H0 = pseudo(0x11111111, 32); // illustrative HASH(CONSTRUCTION)
const H1 = pseudo(0x22222222, 32); // illustrative HASH(H0 || IDENTIFIER)
const H2 = pseudo(0x33333333, 32); // mixed with responder's static pubkey
const CK0 = H0;
// ---------- section data ----------
const sections = [];
function addSection(id, color, title, desc, bodyHTML){
sections.push({id, color, title, desc, bodyHTML});
}
addSection('protocol-id', 'var(--c-hash)',
'Protocol Construction',
'Deriving the initial chaining key and hash from fixed protocol strings',
`
<p>
Before any bytes are exchanged, both peers initialize the same starting
state from two fixed ASCII strings baked into the protocol itself. This
guarantees that a WireGuard handshake can never be confused with a
handshake from a different protocol or a different version of Noise.
</p>
<pre class="code">CONSTRUCTION = <span class="s">"${CONSTRUCTION}"</span>
IDENTIFIER = <span class="s">"${IDENTIFIER}"</span>
chaining_key = HASH(CONSTRUCTION)
hash = HASH(chaining_key || IDENTIFIER)</pre>
${fieldHTML('var(--c-hash)','chaining_key = HASH(CONSTRUCTION)', CK0, [
'The 32-byte chaining key (<code>ck</code>) accumulates entropy from every key exchange that follows.'
])}
${fieldHTML('var(--c-hash)','hash = HASH(ck || IDENTIFIER)', H1, [
'The running transcript hash (<code>h</code>) authenticates every message field exchanged so far, the same role a TLS transcript hash plays.'
])}
<p>
The initiator then mixes in the responder's known static public key,
since in the <code>IK</code> pattern the initiator already knows who it
is talking to before the handshake begins:
</p>
${fieldHTML('var(--c-hash)','hash = HASH(h || responder_static_public)', H2, [])}
`
);
addSection('static-keys', 'var(--c-privkey)',
'Static Key Pairs',
'Long-term Curve25519 identity keys for both peers',
`
<p>
Every WireGuard peer has one long-term Curve25519 key pair that acts as
its permanent identity — this is what goes in a peer's
<code>[Interface]</code> / <code>[Peer]</code> config block as
<code>PrivateKey</code> and <code>PublicKey</code>. Unlike TLS, there is
no certificate: a peer is authorized simply by having its public key
listed in the other side's configuration.
</p>
${fieldHTML('var(--c-privkey)','Initiator static private key', Si_priv, [
'Selected as 32 random bytes and then "clamped" per RFC 7748 before use as an X25519 scalar.'
])}
${fieldHTML('var(--c-pubkey)','Initiator static public key', Si_pub, [
'Computed as <code>X25519(private_key, base_point_9)</code>. This is what the initiator hands the responder out of band, e.g. by pasting it into a config file.'
])}
${fieldHTML('var(--c-privkey)','Responder static private key', Sr_priv, [])}
${fieldHTML('var(--c-pubkey)','Responder static public key', Sr_pub, [
'Already known to the initiator before the handshake starts — this is the <code>IK</code> ("initiator knows") property of the Noise pattern.'
])}
<div class="callout note">
Because both static public keys are exactly 32 bytes and there is no
certificate chain or signature to verify, a WireGuard config line is
often just a single base64-encoded line such as
<code>PublicKey = HIgo9xNzJMWLKASShiTqIybxZ0U3wGLiUeJ1PKf8ykw=</code>.
</div>
`
);
addSection('init-ephemeral', 'var(--c-pubkey)',
'Initiator: Ephemeral Key Generation',
'A fresh, single-use key pair generated for this handshake only',
`
<p>
To begin the handshake, the initiator generates a brand new ephemeral
Curve25519 key pair, used for this session only and discarded
afterward. This is what provides WireGuard's forward secrecy: even if a
static private key is later compromised, past sessions cannot be
decrypted because their ephemeral keys are long gone.
</p>
${fieldHTML('var(--c-privkey)','Initiator ephemeral private key', Ei_priv, [])}
${fieldHTML('var(--c-pubkey)','Initiator ephemeral public key', Ei_pub, [
'Sent in the clear as the first field of the handshake initiation message below.'
])}
<pre class="code">chaining_key = KDF1(chaining_key, ephemeral_public)
hash = HASH(hash || ephemeral_public)</pre>
<p>
Mixing the ephemeral public key into both the chaining key and the
transcript hash means every later derived key implicitly depends on it,
and any tampering with this field downstream will cause MAC
verification to fail.
</p>
`
);
addSection('init-dh1', 'var(--c-hash)',
'Initiator: First Key Exchange (e<sub>i</sub> ↔ S<sub>r</sub>)',
'Mixing a Diffie-Hellman result into the chaining key for the first time',
`
<p>
The initiator now performs a Diffie-Hellman exchange between its own
fresh ephemeral private key and the responder's long-term static public
key. Only someone holding the responder's real static private key will
ever be able to reproduce this value.
</p>
<pre class="code">dh1 = DH(ephemeral_private_i, static_public_r)
chaining_key, key = KDF2(chaining_key, dh1)</pre>
<p>
The resulting <code>key</code> is then used to encrypt the initiator's
own static public key, so that it never appears on the wire in plain
text:
</p>
<pre class="code">encrypted_static = AEAD(key, 0, static_public_i, hash)
hash = HASH(hash || encrypted_static)</pre>
${fieldHTML('var(--c-enc)','encrypted_static = AEAD( key, static_public_i )', encStatic, [
'ChaCha20-Poly1305 ciphertext (32 bytes) plus a 16-byte authentication tag — 48 bytes total.',
'This is the field that lets the responder learn <em>who</em> is connecting, without an eavesdropper learning it too.'
])}
`
);
addSection('init-dh2', 'var(--c-hash)',
'Initiator: Second Key Exchange (S<sub>i</sub> ↔ S<sub>r</sub>)',
'A second DH result authenticates the initiator and hides the timestamp',
`
<p>
A second Diffie-Hellman exchange is performed, this time between both
peers' long-term static keys. Because this result can only be
reproduced by someone holding the initiator's real static private key,
it is what ultimately authenticates the initiator to the responder.
</p>
<pre class="code">dh2 = DH(static_private_i, static_public_r)
chaining_key, key = KDF2(chaining_key, dh2)
encrypted_timestamp = AEAD(key, 0, TAI64N_timestamp, hash)
hash = HASH(hash || encrypted_timestamp)</pre>
${fieldHTML('var(--c-enc)','timestamp (plaintext, before encryption)', timestamp, [
'A 12-byte TAI64N timestamp: 8 bytes of seconds since the TAI epoch, then 4 bytes of nanoseconds.',
'Strictly increasing timestamps are how the responder rejects replayed initiation messages without keeping per-message state.'
])}
${fieldHTML('var(--c-enc)','encrypted_timestamp = AEAD( key, timestamp )', encTimestamp, [
'12 bytes of ciphertext plus a 16-byte tag — 28 bytes total.'
])}
`
);
addSection('msg-initiation', 'var(--c-index)',
'Wire Format: Handshake Initiation',
'The single UDP packet the initiator sends to begin a session — 148 bytes',
`
<p>
Every field generated so far is assembled into one fixed-size, 148-byte
UDP packet. There is no record framing or length prefix the way TLS
uses — WireGuard's message types are fixed-size by design, which keeps
parsing trivial and avoids giving an attacker a length oracle.
</p>
<table class="layout">
<tr><th>bytes</th><th>field</th><th>value</th></tr>
<tr><td class="bytes">0</td><td>message type</td><td><code>0x01</code></td></tr>
<tr><td class="bytes">1–3</td><td>reserved</td><td><code>00 00 00</code></td></tr>
<tr><td class="bytes">4–7</td><td>sender index</td><td><code>${hexSpaced(senderIndexI)}</code></td></tr>
<tr><td class="bytes">8–39</td><td>unencrypted ephemeral</td><td>32 bytes</td></tr>
<tr><td class="bytes">40–87</td><td>encrypted static</td><td>48 bytes</td></tr>
<tr><td class="bytes">88–115</td><td>encrypted timestamp</td><td>28 bytes</td></tr>
<tr><td class="bytes">116–131</td><td>mac1</td><td>16 bytes</td></tr>
<tr><td class="bytes">132–147</td><td>mac2</td><td>16 bytes</td></tr>
</table>
${fieldHTML('var(--c-type)','message type', new Uint8Array([0x01]), ['<code>0x01</code> = handshake initiation. The other defined types are <code>0x02</code> (response), <code>0x03</code> (cookie reply), and <code>0x04</code> (transport data).'])}
${fieldHTML('var(--c-type)','reserved', new Uint8Array([0,0,0]), ['Always zero on the wire today; reserved for future use.'])}
${fieldHTML('var(--c-index)','sender index', senderIndexI, ['A random 32-bit value the initiator picks to label this session locally; the responder will echo it back on every reply so the initiator can demultiplex packets.'])}
${fieldHTML('var(--c-pubkey)','unencrypted ephemeral', Ei_pub, ['The initiator ephemeral public key from the previous step, sent in the clear.'])}
${fieldHTML('var(--c-enc)','encrypted static', encStatic, ['The encrypted initiator static public key, from "First Key Exchange".'])}
${fieldHTML('var(--c-enc)','encrypted timestamp', encTimestamp, ['The encrypted TAI64N timestamp, from "Second Key Exchange".'])}
${fieldHTML('var(--c-mac)','mac1', mac1_init, [
'A keyed BLAKE2s-128 MAC over every preceding byte of the message, keyed by <code>HASH(LABEL_MAC1 || responder_static_public)</code>.',
'Lets the responder cheaply drop garbage or replayed packets before doing any DH math at all — an important defense against denial-of-service.'
])}
${fieldHTML('var(--c-mac)','mac2', mac2_zero, [
'Zero unless the responder is currently under load and has issued this initiator a rate-limiting cookie via a Cookie Reply (type <code>0x03</code>) message.'
])}
`
);
addSection('resp-validate', 'var(--c-mac)',
'Responder: Receive & Authenticate',
'Checking mac1, decrypting the static key, and rejecting replays',
`
<p>
On receipt, the responder works through the same packet in three cheap
steps before it ever has to do a Diffie-Hellman computation:
</p>
<ol style="padding-left:20px; color:#2c333b;">
<li style="margin-bottom:8px;"><strong>Verify mac1.</strong> Recompute the keyed MAC over the first 116 bytes using its own static public key. A mismatch means the packet was corrupted, forged, or not really meant for this responder, and it is silently dropped.</li>
<li style="margin-bottom:8px;"><strong>Replay the same key derivation.</strong> The responder already knows every initiator it's willing to talk to, so it can try each configured peer's static public key as <code>static_public_i</code> and re-derive <code>dh1</code> / <code>dh2</code> until <code>encrypted_static</code> decrypts successfully — that's how it learns which configured peer is connecting.</li>
<li>
<strong>Check the timestamp.</strong> The decrypted TAI64N timestamp must be strictly greater than the last one seen from this peer, or the packet is treated as a replay and dropped.
</li>
</ol>
<div class="callout">
This ordering is deliberate: an attacker who doesn't know any valid
static key, or who replays an old packet, gets rejected using only fast
symmetric-key operations — never burning a relatively expensive Curve25519
scalar multiplication on bytes that were never going to be valid.
</div>
`
);
addSection('resp-ephemeral', 'var(--c-pubkey)',
'Responder: Ephemeral Key Generation',
'The responder\'s own fresh, single-use key pair',
`
<p>
The responder generates its own ephemeral key pair, mixes its public
half into the running hash and chaining key exactly as the initiator
did, and then performs two more Diffie-Hellman exchanges that bind the
response to both ephemeral keys:
</p>
${fieldHTML('var(--c-privkey)','Responder ephemeral private key', Er_priv, [])}
${fieldHTML('var(--c-pubkey)','Responder ephemeral public key', Er_pub, [])}
<pre class="code">chaining_key = KDF1(chaining_key, ephemeral_public_r)
hash = HASH(hash || ephemeral_public_r)
dh3 = DH(ephemeral_private_r, ephemeral_public_i)
chaining_key = KDF1(chaining_key, dh3)
dh4 = DH(ephemeral_private_r, static_public_i)
chaining_key = KDF1(chaining_key, dh4)</pre>
<p>
Two more DH results, both involving the responder's fresh ephemeral
key, mean the final session keys depend on <em>four</em> independent
Diffie-Hellman exchanges in total — a property the Noise framework
calls full "interactive" forward secrecy.
</p>
`
);
addSection('resp-psk', 'var(--c-hash)',
'Mixing in the Pre-Shared Key',
'The "psk2" half of IKpsk2 — an optional extra symmetric secret',
`
<p>
WireGuard optionally supports an out-of-band, manually configured
32-byte symmetric pre-shared key per peer (<code>PresharedKey</code> in
the config file). Mixing it in adds protection against a
future large-scale quantum computer breaking Curve25519: even if every
Diffie-Hellman exchange above were retroactively broken, the session
keys would still depend on this symmetric secret.
</p>
${fieldHTML('var(--c-hash)','preshared_key (configured out of band)', PSK, [
'All-zero by default if the peer config has no <code>PresharedKey</code> set, in which case this step is a no-op.'
])}
<pre class="code">chaining_key, tau, key = KDF3(chaining_key, preshared_key)
hash = HASH(hash || tau)
encrypted_nothing = AEAD(key, 0, [empty], hash)
hash = HASH(hash || encrypted_nothing)</pre>
${fieldHTML('var(--c-enc)','encrypted_nothing = AEAD( key, "" )', encNothing, [
'There is no plaintext payload here — the point of this field is purely the 16-byte authentication tag, proving the responder correctly derived every key up to this point.'
])}
`
);
addSection('msg-response', 'var(--c-index)',
'Wire Format: Handshake Response',
'The responder\'s single reply packet — 92 bytes',
`
<table class="layout">
<tr><th>bytes</th><th>field</th><th>value</th></tr>
<tr><td class="bytes">0</td><td>message type</td><td><code>0x02</code></td></tr>
<tr><td class="bytes">1–3</td><td>reserved</td><td><code>00 00 00</code></td></tr>
<tr><td class="bytes">4–7</td><td>sender index</td><td><code>${hexSpaced(senderIndexR)}</code></td></tr>
<tr><td class="bytes">8–11</td><td>receiver index</td><td><code>${hexSpaced(senderIndexI)}</code></td></tr>
<tr><td class="bytes">12–43</td><td>unencrypted ephemeral</td><td>32 bytes</td></tr>
<tr><td class="bytes">44–59</td><td>encrypted nothing</td><td>16 bytes</td></tr>
<tr><td class="bytes">60–75</td><td>mac1</td><td>16 bytes</td></tr>
<tr><td class="bytes">76–91</td><td>mac2</td><td>16 bytes</td></tr>
</table>
${fieldHTML('var(--c-type)','message type', new Uint8Array([0x02]), [])}
${fieldHTML('var(--c-index)','sender index', senderIndexR, ['The responder\'s own freshly chosen session index for this connection.'])}
${fieldHTML('var(--c-index)','receiver index', senderIndexI, ['Echoes the initiator\'s sender index from the initiation message, so the initiator can match this reply to the right in-flight handshake.'])}
${fieldHTML('var(--c-pubkey)','unencrypted ephemeral', Er_pub, [])}
${fieldHTML('var(--c-enc)','encrypted nothing', encNothing, [])}
${fieldHTML('var(--c-mac)','mac1', mac1_resp, ['Keyed this time by <code>HASH(LABEL_MAC1 || initiator_static_public)</code>, since the responder now knows who it is replying to.'])}
${fieldHTML('var(--c-mac)','mac2', mac2_resp, ['Zero unless the initiator was previously sent a rate-limiting cookie.'])}
<p>
Notice how much smaller this message is than the initiation: 92 bytes
versus 148. The responder never needs to send its static key or a
timestamp — the initiator already knew the responder's static public
key before the handshake even started.
</p>
`
);
addSection('transport-keys', 'var(--c-hash)',
'Deriving Transport Keys',
'Both peers independently arrive at the same pair of session keys',
`
<p>
Once the initiator receives and validates the response — checking
mac1, then mac2, then verifying <code>encrypted_nothing</code> using
its own copy of the chaining key — both sides run one final key
derivation step and arrive at an identical pair of 32-byte transport
keys, without ever having transmitted either one:
</p>
<pre class="code">send_key, recv_key = KDF2(chaining_key, [empty])</pre>
<p>
Each side labels these symmetrically: the key the initiator uses to
<em>send</em> is the same key the responder uses to <em>receive</em>,
and vice versa.
</p>
${fieldHTML('var(--c-enc)','initiator → responder transport key', pseudo(0xa1a1a1a1, 32), [])}
${fieldHTML('var(--c-enc)','responder → initiator transport key', pseudo(0xb2b2b2b2, 32), [])}
<div class="callout note">
The handshake is now complete and its ephemeral private keys and
intermediate chaining-key state are erased. WireGuard automatically
starts a brand new handshake — generating fresh ephemeral keys all
over again — every 2 minutes, bounding how much traffic any one pair
of transport keys ever protects.
</div>
`
);
addSection('transport-format', 'var(--c-counter)',
'Wire Format: Transport Data',
'How everyday encrypted packets look once the tunnel is up',
`
<table class="layout">
<tr><th>bytes</th><th>field</th><th>value</th></tr>
<tr><td class="bytes">0</td><td>message type</td><td><code>0x04</code></td></tr>
<tr><td class="bytes">1–3</td><td>reserved</td><td><code>00 00 00</code></td></tr>
<tr><td class="bytes">4–7</td><td>receiver index</td><td>4 bytes</td></tr>
<tr><td class="bytes">8–15</td><td>counter</td><td>8 bytes</td></tr>
<tr><td class="bytes">16…</td><td>encrypted data</td><td>plaintext length + 16-byte tag</td></tr>
</table>
<p>
Say the initiator now sends the plaintext payload <code>ping</code> —
in WireGuard's normal operation this would actually be a full IP
packet, since WireGuard tunnels raw IP, not arbitrary application
streams.
</p>
${fieldHTML('var(--c-type)','message type', new Uint8Array([0x04]), [])}
${fieldHTML('var(--c-index)','receiver index', senderIndexR, ['Copied from the handshake response, so the responder knows instantly which session\'s keys to use — no lookup by IP or port required.'])}
${fieldHTML('var(--c-counter)','counter', counter0, [
'A 64-bit value that starts at zero and increments by one for every packet, used as the ChaCha20-Poly1305 nonce.',
'The receiver tracks a sliding window of recently-seen counters to reject replayed packets, similar in spirit to an IPsec or DTLS sequence number.'
])}
${fieldHTML('var(--c-enc)','encrypted data = AEAD( send_key, counter, "ping" )', pingCipher, [
'4 bytes of ciphertext plus a 16-byte tag.'
])}
<p>The responder decrypts it, and replies in kind with its own counter starting at zero on its own send key:</p>
${fieldHTML('var(--c-enc)','encrypted data = AEAD( send_key, counter, "pong" )', pongCipher, [])}
<div class="callout note">
Because the type byte is the only fixed marker and the rest of the
packet is uniformly encrypted, transport data packets carry no
distinguishing plaintext metadata beyond size — one reason WireGuard
traffic is comparatively hard to fingerprint on the wire.
</div>
`
);
addSection('lifecycle', 'var(--c-hash)',
'Session Lifecycle',
'Rekeying, idle timeouts, and what happens when a handshake never completes',
`
<p>
A handshake is automatically re-initiated under any of these
conditions, all enforced independently by each peer's local timers:
</p>
<ul style="padding-left:20px; color:#2c333b;">
<li style="margin-bottom:8px;"><strong>Every 120 seconds</strong> of an active session, regardless of traffic, to bound the lifetime of any one set of transport keys.</li>
<li style="margin-bottom:8px;"><strong>After roughly 2<sup>60</sup> transport messages</strong> on one set of keys, since the 64-bit counter must never repeat for a given key.</li>
<li>
<strong>Whenever new outbound traffic arrives</strong> and no valid session exists yet — for example, the very first packet to a peer.
</li>
</ul>
<p>
If a handshake initiation gets no response, the initiator retries with
exponential backoff. If the responder is being flooded with initiation
attempts, it can reply with a <strong>Cookie Reply</strong> (message
type <code>0x03</code>) instead of doing the work of a full handshake —
this is the rate-limiting mechanism mac2 exists to support, conceptually
similar to a TCP SYN cookie.
</p>
`
);
// ---------- render accordion ----------
const root = document.getElementById('sections');
sections.forEach(s=>{
const d = document.createElement('details');
d.className = 'section';
d.id = s.id;
d.innerHTML = `
<summary>
<span class="swatch" style="background:${s.color}"></span>
<div style="flex:1;">
<div class="stitle">${s.title}</div>
<div class="sdesc">${s.desc}</div>
</div>
<span class="chev"></span>
</summary>
<div class="sbody">${s.bodyHTML}</div>
`;
root.appendChild(d);
});
document.getElementById('openAll').addEventListener('click', ()=>{
document.querySelectorAll('details.section').forEach(d=>d.open = true);
});
document.getElementById('closeAll').addEventListener('click', ()=>{
document.querySelectorAll('details.section').forEach(d=>d.open = false);
});
// ---------- overview diagram ----------
document.getElementById('overview-diagram').innerHTML = `
<svg class="diagram" viewBox="0 0 700 230" xmlns="http://www.w3.org/2000/svg">
<text x="60" y="24" class="peer">Initiator</text>
<text x="580" y="24" class="peer" text-anchor="end">Responder</text>
<line x1="80" y1="36" x2="80" y2="210" stroke="#cfd5db" stroke-width="2"/>
<line x1="600" y1="36" x2="600" y2="210" stroke="#cfd5db" stroke-width="2"/>
<line x1="80" y1="70" x2="600" y2="70" stroke="${'#2563eb'}" stroke-width="2" marker-end="url(#arrow)"/>
<text x="340" y="62" text-anchor="middle" class="msg-label">handshake initiation — type 0x01, 148 bytes</text>
<line x1="600" y1="110" x2="80" y2="110" stroke="${'#0f9960'}" stroke-width="2" marker-end="url(#arrow2)"/>
<text x="340" y="102" text-anchor="middle" class="msg-label">handshake response — type 0x02, 92 bytes</text>
<line x1="80" y1="150" x2="600" y2="150" stroke="${'#d97706'}" stroke-width="2" marker-end="url(#arrow)"/>
<text x="340" y="142" text-anchor="middle" class="msg-label">transport data "ping" — type 0x04</text>
<line x1="600" y1="190" x2="80" y2="190" stroke="${'#d97706'}" stroke-width="2" marker-end="url(#arrow2)"/>
<text x="340" y="182" text-anchor="middle" class="msg-label">transport data "pong" — type 0x04</text>
<defs>
<marker id="arrow" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto" markerUnits="strokeWidth">
<path d="M0,0 L6,3 L0,6 Z" fill="#2563eb"/>
</marker>
<marker id="arrow2" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto" markerUnits="strokeWidth">
<path d="M0,0 L6,3 L0,6 Z" fill="#0f9960"/>
</marker>
</defs>
</svg>`;
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment