EWSP Wire Format
See how WakeLink clients and agents authenticate sessions, derive per-session keys, and exchange encrypted command packets over untrusted networks.
Security properties
- XChaCha20-Poly1305 AEAD — authenticated encryption, tampering detected automatically
- HKDF-SHA256 session key derivation — unique key per session, forward secrecy
- HMAC-SHA256 handshake auth — device identity verified before any data flows
- 64-bit sequence numbers with 64-packet replay-protection sliding window
- Blind relay — server never holds session keys, never decrypts payloads
- MAC addresses and commands are always inside ciphertext, opaque to the server
Cryptographic Primitives
EWSP uses industry-standard cryptographic algorithms:
| Algorithm | Standard | Purpose |
|---|---|---|
SHA-256 | FIPS 180-4 | Master key derivation from agent token |
HMAC-SHA256 | RFC 2104 | Handshake authentication (session_start) |
HKDF-SHA256 | RFC 5869 | Session key derivation from shared randomness |
XChaCha20-Poly1305 | RFC 8439 / extended nonce | AEAD encryption of all payloads |
Poly1305 | RFC 7539 | 16-byte authentication tag appended to each ciphertext |
Protocol Constants
EWSP protocol constants and limits:
| Constant | Value | Description |
|---|---|---|
SESSION_ID_SIZE | 8 bytes | Session identifier (transmitted as 16-char hex) |
RANDOM_SIZE | 16 bytes | client_random / device_random challenge length |
NONCE_SIZE | 12 bytes | XChaCha20-Poly1305 nonce (session_id[0:4] ‖ seq[4B BE] ‖ 0x00000000) |
AEAD_TAG_SIZE | 16 bytes | Poly1305 authentication tag, appended to ciphertext |
MAX_SESSIONS | 8 | Concurrent EWSP sessions per device |
MAX_PAYLOAD | 500 bytes | Maximum plaintext payload size (DoS limit) |
SEQ_WINDOW | 64 packets | Replay protection sliding window |
SESSION_TTL | 3600 s | Session expires after 1 hour of inactivity |
Packet Structure
Every EWSP packet has an unencrypted outer envelope (visible to the relay for routing) and an encrypted inner payload (opaque to everyone except client + device).
{
"v": "1.0", // protocol version string
"sid": "a1b2c3d4e5f6a7b8", // 8-byte session ID as 16-char hex
"seq": 42, // 64-bit unsigned sequence number
"p": "base64url(ciphertext)" // encrypted inner payload + 16-byte Poly1305 tag
}{
"cmd": "wake",
"d": { "mac": "AA:BB:CC:DD:EE:FF" },
"rid": "X7K2M9P1" // 8-char request ID for response matching
}The server only sees v, sid, and seq — enough to route and detect replays. The mac address is always inside the ciphertext.
Session Handshake
A 2-message handshake establishes a shared session key before any encrypted packets flow. The relay forwards these messages without reading them.
{
"type": "session_start",
"client_random": "hex(16 random bytes)", // 32-char hex
"auth": "hex(HMAC-SHA256(master_key, client_random))"// 64-char hex
}
// master_key = SHA-256(agent_token)
// agent_token: stored in ESP32 firmware, never transmitted{
"type": "session_ready",
"session_id": "hex(8 random bytes)", // 16-char hex — used for routing
"device_random": "hex(16 random bytes)", // 32-char hex
"expires_in": 3600
}master_key = SHA-256(agent_token) // 32 bytes
session_key = HKDF-SHA256(
ikm = client_random || device_random, // 32 bytes concatenated
salt = master_key,
info = b"ewsp_session",
len = 32 // 32 bytes output
)
// session_key is NEVER sent over the networkAfter the handshake, both sides independently derive the same session_key. The server never sees it. Past sessions remain confidential even if future keys are compromised.
Encryption
nonce = session_id[0:4] // first 4 bytes of session_id
|| seq.to_bytes(4, 'big') // sequence number, big-endian 4 bytes
|| b'\x00\x00\x00\x00' // 4 zero padding bytes
// Total: 12 bytes (XChaCha20-Poly1305 nonce size)aad = f"1.0|{session_id_hex}|{seq}"
// Example: "1.0|a1b2c3d4e5f6a7b8|42"
// Prevents version downgrade and cross-session attacksciphertext = XChaCha20_Poly1305.encrypt(
key = session_key, // 32 bytes, HKDF-derived
nonce = nonce, // 12 bytes
plaintext = inner_json, // {"cmd":"wake","d":{...},"rid":"..."}
aad = aad
)
// Output: ciphertext || 16-byte Poly1305 tag
// Encoded as base64url → stored in packet["p"]inner_json = XChaCha20_Poly1305.decrypt(
key = session_key,
nonce = nonce, // reconstructed from sid + seq
ciphertext = base64url_decode(p), // includes tag
aad = aad
)
// Poly1305 tag verification is automatic
// If ciphertext is tampered → decryption raises AuthenticationError, packet droppedReplay Protection
// Receiver maintains sliding window bitmap of last 64 sequence numbers
// For each incoming packet with seq N:
if seq <= last_accepted - 64:
reject("too old")
if seq in bitmap:
reject("replay detected")
if seq > last_accepted + 1000:
reject("sequence jump too large — DoS protection")
// Otherwise: accept, mark seq in bitmap
// Because nonce includes seq, a replayed packet would reuse the same nonce.
// Poly1305 would technically verify it (same key+nonce = same tag).
// The sliding window catches this before Poly1305 is even invoked.Complete Wake Flow
Full end-to-end wake command flow:
| Step | Phase | Direction | Description |
|---|---|---|---|
1 | Auth | Client → Server | WebSocket upgrade + auth (api_token, client_type: client) |
2 | Auth | ESP32 → Server | WebSocket upgrade + auth (api_token, client_type: device, agent_id) |
3 | Handshake | Client → Device | session_start with client_random, HMAC-SHA256(master_key, client_random) |
4 | Handshake | Device → Client | session_ready with session_id, device_random, expires_in: 3600 |
5 | Encrypt | Client (local) | session_key = HKDF(client_random + device_random, master_key, "ewsp_session") |
6 | Send | Client → Server → Device | Encrypted payload with v:1.0, sid, seq, ciphertext+tag |
7 | WOL | Device (local) | Decrypt inner payload, send UDP magic packet to AA:BB:CC:DD:EE:FF |
8 | ACK | Device → Client | wake_ack with rid, ok:true (encrypted) |
Server only sees: sid, seq, v — MAC address and command are always inside ciphertext