EWSP Wire Format

See how WakeLink clients and agents authenticate sessions, derive per-session keys, and exchange encrypted command packets over untrusted networks.

Tip
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 — agent 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:

AlgorithmStandardPurpose
SHA-256FIPS 180-4Master key derivation from agent token
HMAC-SHA256RFC 2104Handshake authentication (session_start)
HKDF-SHA256RFC 5869Session key derivation from shared randomness
XChaCha20-Poly1305RFC 8439 / extended nonceAEAD encryption of all payloads
Poly1305RFC 753916-byte authentication tag appended to each ciphertext

Protocol Constants

EWSP protocol constants and limits:

ConstantValueDescription
SESSION_ID_SIZE8 bytesSession identifier (transmitted as 16-char hex)
RANDOM_SIZE16 bytesclient_random / device_random challenge length
NONCE_SIZE12 bytesXChaCha20-Poly1305 nonce (session_id[0:4] || seq[4B BE] || 0x00000000)
AEAD_TAG_SIZE16 bytesPoly1305 authentication tag, appended to ciphertext
MAX_SESSIONS8Concurrent EWSP sessions per agent
MAX_PAYLOAD500 bytesMaximum plaintext payload size (DoS limit)
SEQ_WINDOW64 packetsReplay protection sliding window
SESSION_TTL3600 sSession expires after 1 hour of inactivity

Packet Structure

Tip
Every EWSP packet has an unencrypted outer envelope (visible to the relay for routing) and an encrypted inner payload (opaque to everyone except client + agent).
OUTER ENVELOPE — PLAINTEXT, RELAY READS THIS TO ROUTE
{
  "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
}
INNER PAYLOAD — ENCRYPTED, SERVER NEVER SEES THIS
{
  "cmd": "wake",
  "d":   { "mac": "AA:BB:CC:DD:EE:FF" },
  "rid": "X7K2M9P1"                  # 8-char request ID for response matching
}
Note
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.

1. CLIENT -> AGENT: SESSION_START
{
  "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
2. AGENT -> CLIENT: SESSION_READY
{
  "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
}
KEY DERIVATION — BOTH SIDES, INDEPENDENTLY, NEVER TRANSMITTED
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 network
Tip
After 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 CONSTRUCTION — 12 BYTES
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 — ADDITIONAL AUTHENTICATED DATA (NOT ENCRYPTED, BUT SIGNED)
aad = f"1.0|{session_id_hex}|{seq}"
# Example: "1.0|a1b2c3d4e5f6a7b8|42"
# Prevents version downgrade and cross-session attacks
ENCRYPT (SENDER)
ciphertext = 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"]
DECRYPT (RECEIVER)
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 dropped

Replay 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:

StepPhaseDirectionDescription
1AuthClient -> ServerWebSocket upgrade + auth (api_token, client_type: client)
2AuthESP32 -> ServerWebSocket upgrade + auth (api_token, client_type: agent, agent_id)
3HandshakeClient <-> agentsession_start with client_random, HMAC-SHA256(master_key, client_random)
4Handshakeagent -> Clientsession_ready with session_id, device_random, expires_in: 3600
5EncryptClient (local)session_key = HKDF(client_random + device_random, master_key, ewsp_session)
6SendClient -> Server -> agentEncrypted payload with v 1.0, sid, seq, ciphertext+tag
7WOLagent (local)Decrypt inner payload, send UDP magic packet to AA BB CC DD EE FF
8ACKagent -> Clientwake_ack with rid, status ok (encrypted)
Note
Server only sees: sid, seq, v — MAC address and command are always inside ciphertext