Reverse Engineering the Tapo KLAP Protocol
When you want to control a TP-Link Tapo smart plug (like the P110) programmatically, you need to solve two fundamental problems:
- Discovery: How do you find Tapo devices on your network?
- Authentication: How do you securely communicate with them?
This document explores both, based on reverse engineering the Tapo protocol by analyzing the open-source Rust implementation.
Part 1: UDP Device Discovery
The Problem
Smart home devices don't come with fixed IP addresses. When a Tapo device joins your network, it gets a dynamic IP from your router via DHCP. So how does the official Tapo app find your devices without knowing their IPs in advance?
The Solution: UDP Broadcast
Tapo uses a UDP broadcast discovery protocol on port 20002. Here's how it works:
┌──────────┐ ┌─────────────┐
│ Client │───UDP Broadcast───►│ Network │
│ │ (port 20002) │ 255.255.255.│
└──────────┘ │ 255 │
└─────────────┘
│
┌──────────────────┼──────────────────┐
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Tapo P1 │ │ Tapo P2 │ │ Router │
│Responds │ │Responds │ │ Ignores │
└─────────┘ └─────────┘ └─────────┘
The process:
- Client sends a UDP packet to broadcast address
255.255.255.255:20002 - All devices on the local network receive the packet
- Tapo devices recognize the packet format and respond
- Client listens for responses and collects responding IP addresses
Discovery Packet Structure
The discovery packet has a specific binary structure:
┌─────────────────────────────────────────────────┐
│ Binary Header (16 bytes) │
├─────────────────────────────────────────────────┤
│ JSON Body (variable) │
└─────────────────────────────────────────────────┘
Header Format (16 bytes):
| Offset | Size | Field | Description |
|---|---|---|---|
| 0 | 1 | Version | Protocol version (2) |
| 1 | 1 | Message Type | Always 0 for discovery |
| 2 | 2 | Operation Code | 1 = discovery request |
| 4 | 2 | Message Size | Size of JSON body in bytes |
| 6 | 1 | Flags | Always 17 for discovery |
| 7 | 1 | Padding | Always 0 |
| 8 | 4 | Device Serial | Random 32-bit number |
| 12 | 4 | CRC32 Checksum | CRC of entire message |
JSON Body:
{
"params": {
"rsa_key": "-----BEGIN PUBLIC KEY-----\n..."
}
}
The JSON contains a freshly-generated 1024-bit RSA public key. This key is generated for each discovery attempt and is not reused.
CRC32 Calculation
The CRC32 checksum ensures message integrity. Here's the process:
- Assemble header with CRC field set to placeholder (e.g.,
0x5A6B7C8D) - Append JSON body
- Calculate CRC32 of the entire message
- Replace placeholder with real CRC in header bytes 12-15
Ruby implementation:
header = [version, msg_type, op_code, msg_size, flags,
padding, device_serial, crc_placeholder].pack('CCnnCCNN')
full_message = header + json_body
real_crc = Digest::CRC32.checksum(full_message)
# Replace CRC at offset 12 (4 bytes, big-endian)
header[12, 4] = [real_crc].pack('N')
final_packet = header + json_body
Why This Approach?
Advantages:
- No need for mDNS or SSDP
- Works across subnet boundaries (with router support)
- Devices can respond with their capabilities in the JSON
- Includes basic integrity checking via CRC
Security Note: The discovery process is unauthenticated. Anyone on the network can discover Tapo devices. Authentication happens in the next step.
Part 2: KLAP Protocol Authentication
What is KLAP?
KLAP (Key and Local Authentication Protocol) is TP-Link's modern authentication protocol for Tapo devices. It replaced the older "Passthrough" protocol that used RSA encryption.
Why did TP-Link create KLAP?
- Performance: AES-128-CBC is much faster than RSA encryption
- Security: Proper mutual authentication prevents replay attacks
- Efficiency: Smaller packet sizes compared to RSA+Base64
Protocol Detection
Before authenticating, clients must detect which protocol the device supports:
# Send a test request
POST http://device_ip/
Body: { "method": "get_device_info" }
# KLAP devices respond with 401 Unauthorized
# Passthrough devices respond with 200 OK + JSON error
Specifically, if you see {"error_code": 1003} in a 200 response, the device is KLAP-only (error 1003 = "method not found" - it doesn't know about the Passthrough handshake method).
Authentication Overview
KLAP authentication is a two-phase handshake process:
┌────────┐ ┌────────┐
│ Client │ │ Device │
└────┬───┘ └───┬────┘
│ │
│ 1. Handshake1: local_seed │
│──────────────────────────────────────────►│
│ │
│ remote_seed + server_hash │
│◄──────────────────────────────────────────│
│ │
│ (Client verifies server_hash) │
│ │
│ 2. Handshake2: client_hash │
│──────────────────────────────────────────►│
│ │
│ 200 OK │
│◄──────────────────────────────────────────│
│ │
│ (Mutual authentication complete) │
│ │
Phase 0: Derive Authentication Hash
Before the handshake begins, the client must derive an auth_hash from the user's credentials:
username_hash = SHA1(username) # SHA1 of email address
password_hash = SHA1(password) # SHA1 of password
auth_hash = SHA256(username_hash || password_hash) # 32 bytes
Why two different hash algorithms?
- Legacy compatibility: Tapo accounts originally used SHA1
- Modern security: The final SHA256 adds a layer of protection
- The
||operator means concatenation (append one after the other)
Phase 1: Handshake1 - Server Authentication
Client sends:
POST http://device_ip/app/handshake1
Content-Type: application/octet-stream
Body: <16 random bytes (local_seed)>
Device responds:
HTTP 200 OK
Set-Cookie: TP_SESSIONID=<session_id>
Body: <16 bytes remote_seed> + <32 bytes server_hash>
Client verification:
local_hash = SHA256(local_seed || remote_seed || auth_hash)
if local_hash == server_hash
# Server has proven it knows the password
# Proceed to phase 2
else
# Wrong password or compromised device
raise "Authentication failed"
end
What just happened?
The device proved it knows your password without the client sending the password over the network. This is challenge-response authentication:
- Client sends a random challenge (
local_seed) - Device combines it with its own seed and the auth hash
- Device hashes everything and sends it back
- Client performs the same calculation and verifies the result
If an attacker intercepts this exchange, they can't derive the password from it (thanks to the one-way nature of SHA256).
Phase 2: Handshake2 - Client Authentication
Now the client must prove it knows the password:
Client sends:
POST http://device_ip/app/handshake2
Cookie: TP_SESSIONID=<session_id>
Content-Type: application/octet-stream
Body: SHA256(remote_seed || local_seed || auth_hash)
Device responds:
HTTP 200 OK
Note: The order of seeds is reversed (remote_seed first, then local_seed). This is intentional - it creates a different hash from Phase 1, preventing replay attacks.
If the device accepts this, mutual authentication is complete. Both sides have proven they know the password without ever transmitting it.
Session Management
The TP_SESSIONID cookie is essential:
- It's set in the Handshake1 response
- It must be included in all subsequent requests
- It expires after a period of inactivity (appears to be ~5 minutes)
- When it expires, you must re-authenticate
Important: Extract only the session ID:
# Full cookie: "TP_SESSIONID=ABC123;TIMEOUT=600;path=/..."
# Extract just: "TP_SESSIONID=ABC123"
cookie_header =~ /TP_SESSIONID=([^;]+)/
cookie = "TP_SESSIONID=#{$1}"
Part 3: Encrypted Communication
After authentication, all communication is encrypted using AES-128-CBC with per-message IVs and signatures.
Key Derivation
From the handshake, we have:
local_seed(16 bytes)remote_seed(16 bytes)auth_hash(32 bytes)
These are combined to derive session keys:
combined = local_seed + remote_seed + auth_hash # 64 bytes
# Derive encryption key (16 bytes)
key = SHA256("lsk" + combined)[0..15]
# Derive IV base (12 bytes)
iv_base = SHA256("iv" + combined)[0..11]
# Derive signature key (28 bytes)
sig_key = SHA256("ldk" + combined)[0..27]
# Initial sequence number (4 bytes as big-endian integer)
seq = SHA256("iv" + combined)[-4..-1].unpack('N').first
Why the prefixes "lsk", "iv", "ldk"?
- It's a form of key separation: each derived key serves a different purpose
- Prevents using the same key material for multiple operations
- "lsk" = Local Session Key
- "ldk" = Local Derived Key (for signatures)
- "iv" = Initialization Vector derivation
Encryption Process
Each request is encrypted independently with an incrementing sequence number:
┌────────────────────────────────────────┐
│ Signature (32 bytes) │ ← SHA256(sig_key || seq || ciphertext)
├────────────────────────────────────────┤
│ Encrypted Payload (variable) │ ← AES-128-CBC(json, key, iv)
└────────────────────────────────────────┘
Steps to send a request:
-
Increment sequence number:
seq += 1 # Atomic increment -
Create message-specific IV:
iv = iv_base + [seq].pack('N') # 12 bytes + 4 bytes = 16 bytes -
Encrypt JSON payload:
cipher = OpenSSL::Cipher.new('AES-128-CBC') cipher.encrypt cipher.key = key cipher.iv = iv cipher.padding = 1 # PKCS7 padding ciphertext = cipher.update(json_payload) + cipher.final -
Create signature:
signature = SHA256(sig_key + [seq].pack('N') + ciphertext) -
Send request:
POST http://device_ip/app/request?seq=<seq> Cookie: TP_SESSIONID=<session_id> Content-Type: application/octet-stream Body: signature + ciphertext
Decryption Process
The device responds with the same format:
response_body = signature + ciphertext
# Skip signature (first 32 bytes)
ciphertext = response_body[32..-1]
# Decrypt
decipher = OpenSSL::Cipher.new('AES-128-CBC')
decipher.decrypt
decipher.key = key
decipher.iv = iv_base + [seq].pack('N') # Same IV as request
decipher.padding = 1
plaintext = decipher.update(ciphertext) + decipher.final
response = JSON.parse(plaintext)
Why This Design?
Per-message IVs: Using a unique IV for each message prevents pattern analysis. Even if you send the same command twice, the ciphertext will be different.
Signatures: The SHA256 signature provides:
- Authentication: Verifies the message came from someone with the session keys
- Integrity: Detects any tampering with the ciphertext
- Replay protection: The sequence number is part of the signature
Sequence numbers: Monotonically increasing sequence numbers prevent replay attacks. The device can reject any message with an out-of-order sequence number.
Part 4: Putting It All Together
Here's a complete flow from discovery to control:
1. Discovery Phase
└─► UDP broadcast to 255.255.255.255:20002
└─► Collect responding IPs
2. Protocol Detection
└─► POST / with test request
└─► Check response code (401 = KLAP, 200 = Passthrough)
3. Authentication Phase
└─► Generate auth_hash from credentials
└─► Handshake1: Exchange seeds, verify server
└─► Handshake2: Prove client identity
└─► Store session cookie and derive keys
4. Operational Phase
└─► For each command:
├─► Increment sequence number
├─► Encrypt JSON request
├─► Add signature
├─► Send with session cookie
├─► Decrypt response
└─► Parse JSON result
Example Commands
Get device info:
klap_request(session, { method: 'get_device_info' })
Turn device on:
klap_request(session, {
method: 'set_device_info',
params: { device_on: true }
})
Get energy usage (P110 only):
klap_request(session, { method: 'get_energy_usage' })
Security Considerations
What KLAP Does Well
- Mutual Authentication: Both client and device prove knowledge of credentials
- No Plaintext Passwords: Credentials never transmitted over the network
- Encrypted Payloads: Commands and responses are hidden from network observers
- Replay Protection: Sequence numbers and signatures prevent replay attacks
- Session Timeout: Limited window for compromised sessions
Potential Weaknesses
- Local Network Only: KLAP is designed for LAN use. No protection against local network attackers
- No Certificate Pinning: HTTP (not HTTPS) means no protection against MITM on local network
- Discovery is Unencrypted: Anyone can enumerate devices on the network
- Session Hijacking: If the session cookie is stolen, attacker has access until timeout
- Brute Force: No rate limiting on authentication attempts (in the protocol itself)
Recommendations
If you're building a Tapo client:
- Use the official Tapo app credentials, not a shared/hardcoded account
- Implement your own rate limiting for failed auth attempts
- Don't store plaintext passwords - use keychain/encrypted storage
- Consider running on an isolated IoT VLAN
- Monitor for unexpected device responses (could indicate tampering)
References & Further Reading
- Original Rust Implementation: github.com/mihai-dinculescu/tapo
- KLAP Protocol File:
tapo/src/api/protocol/klap_protocol.rs - KLAP Cipher File:
tapo/src/api/protocol/klap_cipher.rs - UDP Discovery: RFC 919 (Broadcasting Internet Datagrams)
- AES-CBC Mode: NIST SP 800-38A
Conclusion
The Tapo KLAP protocol is a well-designed authentication and encryption system for IoT devices. It balances security with performance, using modern cryptographic primitives (AES, SHA256) while maintaining compatibility with resource-constrained devices.
By reverse engineering it, we've learned:
- How UDP broadcast discovery works in practice
- The importance of mutual authentication
- How to derive multiple keys from shared secret material
- Why per-message IVs and signatures are crucial for secure communication
This knowledge enables us to build custom clients, integrate Tapo devices into home automation systems, or simply satisfy our curiosity about how these devices actually work under the hood.