← Back to Garden
seedling ·
iot reverse-engineering cryptography networking tapo smart-home

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:

  1. Discovery: How do you find Tapo devices on your network?
  2. 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:

  1. Client sends a UDP packet to broadcast address 255.255.255.255:20002
  2. All devices on the local network receive the packet
  3. Tapo devices recognize the packet format and respond
  4. 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:

  1. Assemble header with CRC field set to placeholder (e.g., 0x5A6B7C8D)
  2. Append JSON body
  3. Calculate CRC32 of the entire message
  4. 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:

  1. Client sends a random challenge (local_seed)
  2. Device combines it with its own seed and the auth hash
  3. Device hashes everything and sends it back
  4. 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:

  1. Increment sequence number:

    seq += 1  # Atomic increment
    
  2. Create message-specific IV:

    iv = iv_base + [seq].pack('N')  # 12 bytes + 4 bytes = 16 bytes
    
  3. 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
    
  4. Create signature:

    signature = SHA256(sig_key + [seq].pack('N') + ciphertext)
    
  5. 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

  1. Mutual Authentication: Both client and device prove knowledge of credentials
  2. No Plaintext Passwords: Credentials never transmitted over the network
  3. Encrypted Payloads: Commands and responses are hidden from network observers
  4. Replay Protection: Sequence numbers and signatures prevent replay attacks
  5. Session Timeout: Limited window for compromised sessions

Potential Weaknesses

  1. Local Network Only: KLAP is designed for LAN use. No protection against local network attackers
  2. No Certificate Pinning: HTTP (not HTTPS) means no protection against MITM on local network
  3. Discovery is Unencrypted: Anyone can enumerate devices on the network
  4. Session Hijacking: If the session cookie is stolen, attacker has access until timeout
  5. 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.