|
<!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‑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‑for‑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 → 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 → 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">▸</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> |