Building an 8-Layer Security Architecture for a $15 Hardware Device

# riscv# c# esp32# lilygo
Building an 8-Layer Security Architecture for a $15 Hardware Devicemakepkg

Building an 8-Layer Security Architecture for a $15 Hardware Device When you're building a...

Building an 8-Layer Security Architecture for a $15 Hardware Device

When you're building a hardware password manager, security isn't optional—it's the entire point.

But how do you secure a $15 ESP32 device that stores sensitive data? The answer: defense in depth.

In this post, I'll break down the 8-layer security architecture I built for SecureGen, a hardware TOTP authenticator and password manager. Each layer defends against different attack types, ensuring that breaching one layer doesn't compromise the system.

![8 Security Layers Architecture](

Why 8 Layers?

Single-layer security is a single point of failure.

If your only protection is encryption and the key gets leaked, game over. If your only protection is authentication and credentials get phished, you're done.

Defense in depth means attackers need to bypass multiple independent layers. Each layer:

  • Targets different attack vectors
  • Uses different techniques
  • Fails independently
  • Slows down attackers

Let's break down each layer.


Layer 1: ECDH Key Exchange 🔐

The Problem

Passwords and TOTP secrets need to be encrypted. But if you hardcode encryption keys, anyone with the source code can decrypt your data.

The Solution

Generate unique encryption keys dynamically using Elliptic Curve Diffie-Hellman (ECDH) with the P-256 curve.

How It Works

// Generate ephemeral key pair
mbedtls_ecdh_context ctx;
mbedtls_ecdh_setup(&ctx, MBEDTLS_ECP_DP_SECP256R1);  // P-256
mbedtls_ecdh_gen_public(&ctx, &olen, public_key, 65, 
                        mbedtls_ctr_drbg_random, &ctr_drbg);

// Compute shared secret
mbedtls_ecdh_calc_secret(&ctx, &olen, shared_secret, 32,
                         mbedtls_ctr_drbg_random, &ctr_drbg);

// Derive encryption key
mbedtls_sha256(shared_secret, 32, encryption_key, 0);
Enter fullscreen mode Exit fullscreen mode

What this prevents: Man-in-the-middle (MITM) attacks

Even if someone intercepts the traffic during key exchange, they can't compute the shared secret without the private keys. And since keys are ephemeral (generated per session), they can't be reused.

Real-World Impact

Before ECDH:

[Attacker intercepts traffic]
→ Attacker sees: Encrypted data with static key
→ Attacker bruteforces key offline
→ Game over
Enter fullscreen mode Exit fullscreen mode

After ECDH:

[Attacker intercepts traffic]
→ Attacker sees: Public keys (useless without private)
→ Attacker can't compute shared secret
→ Encrypted data remains secure
Enter fullscreen mode Exit fullscreen mode

Layer 2: Session Encryption 🔒

The Problem

Bluetooth (BLE) has built-in encryption, but it's not enough for sensitive data like passwords.

The Solution

Double encryption: AES-128 at the BLE layer + AES-256 at the application layer.

How It Works

// BLE layer (automatic)
esp_ble_gap_set_security_param(ESP_BLE_SM_AUTHEN_REQ_MODE, 
                                &auth_req, sizeof(uint8_t));
auth_req = ESP_LE_AUTH_REQ_SC_MITM_BOND;  // AES-128

// Application layer (manual)
void encrypt_password(const char* password, uint8_t* output) {
    mbedtls_aes_context aes;
    mbedtls_aes_setkey_enc(&aes, session_key, 256);  // AES-256

    uint8_t iv[16];
    esp_fill_random(iv, 16);  // Random IV per encryption

    mbedtls_aes_crypt_cbc(&aes, MBEDTLS_AES_ENCRYPT, 
                          strlen(password), iv, 
                          (uint8_t*)password, output);
}
Enter fullscreen mode Exit fullscreen mode

What this prevents: Eavesdropping and packet sniffing

Even if an attacker breaks BLE encryption (unlikely but possible), they still face AES-256 application-layer encryption.

Real-World Impact

Single encryption:

[BLE vulnerability discovered]
→ Attacker breaks BLE encryption
→ Passwords visible in plaintext
→ Total compromise
Enter fullscreen mode Exit fullscreen mode

Double encryption:

[BLE vulnerability discovered]
→ Attacker breaks BLE encryption
→ Attacker sees: More encrypted data (AES-256)
→ Passwords still secure
Enter fullscreen mode Exit fullscreen mode

Layer 3: Dynamic API Endpoints 🎲

The Problem

Static API endpoints are easy targets for automated scanners. Tools like dirb, gobuster, and nikto can discover them in seconds.

The Solution

Obfuscate endpoint URLs using SHA-256 and change them on every device boot.

How It Works

// Generate dynamic endpoint
String generate_endpoint(const char* base, uint32_t seed) {
    char hash_input[64];
    snprintf(hash_input, sizeof(hash_input), "%s-%u", base, seed);

    uint8_t hash[32];
    mbedtls_sha256((uint8_t*)hash_input, strlen(hash_input), hash, 0);

    char endpoint[65];
    for(int i = 0; i < 32; i++) {
        sprintf(&endpoint[i*2], "%02x", hash[i]);
    }

    return String("/") + String(endpoint);
}

// Real endpoint on device
uint32_t boot_seed = esp_random();
String totp_endpoint = generate_endpoint("totp", boot_seed);
// Result: /a7f3c9e8b2d4f1a6c8e5d7f9b3a1c5e8d2f4a6b8c0e2d4f6a8b0c2e4f6a8b0c2
Enter fullscreen mode Exit fullscreen mode

What this prevents: Automated API discovery and vulnerability scanning

Scanners expect endpoints like /api/totp or /passwords. When they get /a7f3c9e8b2d4f1a6..., they don't know what it does without manual analysis.

Real-World Impact

Before (static endpoints):

$ gobuster dir -u http://device.local -w wordlist.txt
Found: /api/totp
Found: /api/passwords
Found: /api/admin

[Automated exploit tools target these]
Enter fullscreen mode Exit fullscreen mode

After (dynamic endpoints):

$ gobuster dir -u http://device.local -w wordlist.txt
No matches found

[Attacker needs to reverse-engineer endpoint generation]
Enter fullscreen mode Exit fullscreen mode

Layer 4: Header Obfuscation 🎭

The Problem

HTTP headers reveal your tech stack. Headers like Server: nginx/1.18.0 or X-Powered-By: Express 4.17.1 tell attackers exactly which vulnerabilities to target.

The Solution

Dynamically map and obfuscate all HTTP headers. Hide metadata.

How It Works

// Header mapping (changes per session)
std::map header_map;

void init_header_obfuscation() {
    header_map["Content-Type"] = "X-" + random_string(8);
    header_map["Authorization"] = "X-" + random_string(8);
    header_map["Server"] = "X-" + random_string(8);
}

void send_response(AsyncWebServerRequest *request, String content) {
    AsyncWebServerResponse *response = request->beginResponse(200, 
        header_map["Content-Type"], content);

    // Remove revealing headers
    response->addHeader(header_map["Server"], "Unknown");

    // Add fake misleading headers
    response->addHeader("X-AspNetMvc-Version", "5.2.7");  // Not using ASP.NET
    response->addHeader("X-Powered-By", "PHP/7.4.3");     // Not using PHP

    request->send(response);
}
Enter fullscreen mode Exit fullscreen mode

What this prevents: Tech stack fingerprinting and targeted exploits

Real-World Impact

Before:

HTTP/1.1 200 OK
Server: ESP-AsyncWebServer/1.2.3
Content-Type: application/json
X-ESP32-Chip: ESP32-D0WDQ6

[Attacker knows: ESP32, specific library versions]
[Searches for CVEs in those versions]
Enter fullscreen mode Exit fullscreen mode

After:

HTTP/1.1 200 OK
X-k8f3a2: application/json
X-9e4c7: Unknown
X-AspNetMvc-Version: 5.2.7
X-Powered-By: PHP/7.4.3

[Attacker sees: Conflicting information]
[Wastes time targeting wrong stack]
Enter fullscreen mode Exit fullscreen mode

Layer 5: Anti-Fingerprinting 👻

The Problem

Even without obvious headers, devices can be fingerprinted by response patterns, timing, and behaviors.

The Solution

Inject fake headers, randomize response patterns, and make the device appear different on each request.

How It Works

void add_fingerprint_confusion(AsyncWebServerResponse *response) {
    // Random fake headers
    const char* fake_frameworks[] = {
        "Django/3.2.4", "Rails/6.1.3", "Laravel/8.40.0", 
        "Express/4.17.1", "Spring/5.3.8"
    };

    response->addHeader("X-Framework", 
        fake_frameworks[esp_random() % 5]);

    // Randomize response time (within reason)
    delay(esp_random() % 50 + 10);  // 10-60ms

    // Vary content encoding
    if(esp_random() % 2) {
        response->addHeader("Content-Encoding", "gzip");
    }

    // Change protocol hints
    response->addHeader("Vary", "Accept-Encoding, User-Agent, Accept-Language");
}
Enter fullscreen mode Exit fullscreen mode

What this prevents: Device fingerprinting and traffic analysis

Real-World Impact

Before:

Request 1: Response in 45ms, Server: ESP32
Request 2: Response in 44ms, Server: ESP32
Request 3: Response in 46ms, Server: ESP32

[Pattern: ESP32 device, consistent timing]
[Attacker: "I know what this is"]
Enter fullscreen mode Exit fullscreen mode

After:

Request 1: Response in 23ms, Framework: Django
Request 2: Response in 51ms, Framework: Rails  
Request 3: Response in 18ms, Framework: Laravel

[Pattern: Inconsistent, conflicting data]
[Attacker: "What the hell is this thing?"]
Enter fullscreen mode Exit fullscreen mode

Layer 6: Honey Pot 🍯

The Problem

You want to know if someone is trying to break in. Passive defenses are invisible.

The Solution

Create fake "vulnerable" endpoints that look tempting but are actually traps. Log all access attempts.

How It Works

// Honey pot endpoints
server.on("/admin", HTTP_GET, [](AsyncWebServerRequest *request){
    log_intrusion_attempt(request->client()->remoteIP().toString(), 
                          "/admin", "Unauthorized access attempt");

    // Return realistic fake data
    String fake_response = R"({
        "users": [
            {"username": "admin", "role": "superuser"},
            {"username": "root", "role": "admin"}
        ],
        "version": "2.4.1",
        "debug": true
    })";

    request->send(200, "application/json", fake_response);
});

server.on("/api/users", HTTP_GET, [](AsyncWebServerRequest *request){
    // Check for SQL injection attempts
    String query = request->arg("id");
    if(query.indexOf("OR 1=1") != -1 || query.indexOf("'") != -1) {
        log_intrusion_attempt(request->client()->remoteIP().toString(),
                              "/api/users", "SQL injection attempt detected");
    }

    request->send(200, "application/json", "[]");  // Empty response
});

// Path traversal trap
server.on("/../../etc/passwd", HTTP_GET, [](AsyncWebServerRequest *request){
    log_intrusion_attempt(request->client()->remoteIP().toString(),
                          request->url(), "Path traversal attempt");

    // Fake passwd file
    String fake_passwd = "root:x:0:0:root:/root:/bin/bash\n"
                         "admin:x:1000:1000:Admin:/home/admin:/bin/bash\n";
    request->send(200, "text/plain", fake_passwd);
});
Enter fullscreen mode Exit fullscreen mode

What this prevents: Brute force attacks and automated exploitation

Real-World Impact

[Attacker scans device]
Attacker: "/admin endpoint found! Jackpot!"
[Accesses /admin]

[Device logs]
IP: 192.168.1.100
Endpoint: /admin
Time: 2026-02-15 14:23:11
User-Agent: python-requests/2.28.1
Action: Rate limited + alerted via web interface

[Attacker wastes time on fake data]
[You get early warning + attacker's IP]
Enter fullscreen mode Exit fullscreen mode

Layer 7: Method Tunneling 🔀

The Problem

Many automated scanners look for specific HTTP methods (GET, POST, DELETE) on endpoints.

The Solution

Tunnel all requests through POST with an encrypted method field. Mask the real HTTP method.

How It Works

// Client-side (JavaScript)
async function apiRequest(endpoint, method, data) {
    const encrypted_method = await encrypt(method, session_key);

    const response = await fetch(endpoint, {
        method: 'POST',  // Always POST
        headers: {
            'Content-Type': 'application/json',
            'X-Method': encrypted_method  // Real method hidden here
        },
        body: JSON.stringify(data)
    });

    return response.json();
}

// Server-side (ESP32)
server.on("/api/*", HTTP_POST, [](AsyncWebServerRequest *request){
    String encrypted_method = request->header("X-Method");
    String real_method = decrypt_method(encrypted_method);

    if(real_method == "GET") {
        handle_get(request);
    } else if(real_method == "DELETE") {
        handle_delete(request);
    } else if(real_method == "PUT") {
        handle_put(request);
    }
});
Enter fullscreen mode Exit fullscreen mode

What this prevents: Pattern-based automated attacks

Real-World Impact

Before:

$ curl -X DELETE http://device.local/api/passwords/1
Password deleted

[Automated scanner detects DELETE is enabled]
[Tries to delete everything]
Enter fullscreen mode Exit fullscreen mode

After:

$ curl -X DELETE http://device.local/api/passwords/1
405 Method Not Allowed

$ curl -X POST http://device.local/api/passwords/1
Missing X-Method header

[Scanner confused: POST required but doesn't work normally]
[Manual analysis needed]
Enter fullscreen mode Exit fullscreen mode

Layer 8: Timing Attack Protection ⏱️

The Problem

Attackers can infer information by measuring response times. For example:

  • Wrong password: 5ms response
  • Correct password but wrong PIN: 50ms response
  • Correct everything: 100ms response

This leaks information without ever succeeding.

The Solution

Add random delays to all security-critical operations. Make timing unpredictable.

How It Works

bool verify_credentials(const char* password, const char* pin) {
    // Start timer
    uint32_t start_time = millis();

    // Actual verification
    bool password_valid = constant_time_compare(password, stored_password);
    bool pin_valid = constant_time_compare(pin, stored_pin);

    bool result = password_valid && pin_valid;

    // Calculate elapsed time
    uint32_t elapsed = millis() - start_time;

    // Target response time: 100-150ms
    uint32_t target_time = 100 + (esp_random() % 50);

    // Add delay to reach target (if needed)
    if(elapsed < target_time) {
        delay(target_time - elapsed);
    }

    return result;
}

// Constant-time string comparison (prevents timing leaks)
bool constant_time_compare(const char* a, const char* b) {
    size_t len_a = strlen(a);
    size_t len_b = strlen(b);

    // Always compare full length (even if lengths differ)
    size_t max_len = (len_a > len_b) ? len_a : len_b;

    int result = len_a ^ len_b;  // 0 if lengths match

    for(size_t i = 0; i < max_len; i++) {
        char char_a = (i < len_a) ? a[i] : 0;
        char char_b = (i < len_b) ? b[i] : 0;
        result |= char_a ^ char_b;
    }

    return result == 0;
}
Enter fullscreen mode Exit fullscreen mode

What this prevents: Timing side-channel attacks and password length inference

Real-World Impact

Before (no timing protection):

# Attacker script
import requests
import time

passwords = ["a", "ab", "abc", "abcd", ...]

for pwd in passwords:
    start = time.time()
    response = requests.post("/auth", json={"password": pwd})
    elapsed = time.time() - start

    print(f"{pwd}: {elapsed:.3f}s")

# Output:
# a: 0.005s      ← Wrong immediately
# ab: 0.005s     ← Wrong immediately  
# abc: 0.007s    ← Took slightly longer! (closer match)
# abcd: 0.005s   ← Back to fast

# Conclusion: Real password starts with "abc"
Enter fullscreen mode Exit fullscreen mode

After (with timing protection):

# Attacker script (same)
# Output:
# a: 0.123s
# ab: 0.147s
# abc: 0.108s
# abcd: 0.135s

# Conclusion: ??? (no pattern)
Enter fullscreen mode Exit fullscreen mode

Putting It All Together

Here's how all 8 layers work in a real scenario:

Attack Scenario: Automated Exploitation Attempt

1. Attacker scans device
   → Layer 3: Dynamic endpoints confuse scanner
   → Layer 4: Fake headers mislead fingerprinting

2. Attacker finds /admin endpoint (honey pot)
   → Layer 6: Access logged, IP recorded
   → Fake data returned, attacker wastes time

3. Attacker tries SQL injection
   → Layer 6: Honey pot detects pattern, logs attempt
   → Layer 5: Randomized responses confuse automation

4. Attacker intercepts BLE traffic
   → Layer 1: ECDH-generated keys are useless without private key
   → Layer 2: Double encryption still protects data

5. Attacker attempts timing attack on auth
   → Layer 8: Random delays hide password validation timing
   → No information leaked

6. Attacker tries brute force via web API
   → Layer 7: Method tunneling breaks automated tools
   → Layer 8: Consistent random timing prevents optimization

Result: Attack fails, you have logs of attempt, attacker wasted hours
Enter fullscreen mode Exit fullscreen mode

Lessons Learned

1. Layering isn't redundancy—it's strategy

Each layer should target different attack vectors. Having 8 encryption layers doesn't help if they all use the same algorithm and key.

Good layering:

  • Layer 1: Key exchange (MITM protection)
  • Layer 2: Encryption (eavesdropping protection)
  • Layer 6: Honey pot (intrusion detection)

Bad layering:

  • Layer 1: AES-256
  • Layer 2: AES-256 again
  • Layer 3: AES-256 again (just slower)

2. Security is about time, not impossibility

No system is unbreakable. But security can make attacks:

  • Take too long (months/years)
  • Cost too much (hardware, time, expertise)
  • Be too risky (early detection, logging)

For a $15 DIY device storing personal passwords, being harder to crack than a corporate server is enough deterrent.

3. Observability matters

The honey pot layer isn't about stopping attacks—it's about knowing they're happening. Early warning is invaluable.

4. Open source security works

Making the code public doesn't weaken security. The architecture still requires:

  • Physical access to the device
  • Extracting hardware-unique keys
  • Bypassing multiple independent layers

"Security through obscurity" is not security. Security through architecture is.


Implementation Complexity

Layer Code Complexity Performance Impact Maintenance
ECDH Medium (mbedTLS) Low (once per session) Low
Session Encryption Low (built-in) Low Low
Dynamic Endpoints Low None Low
Header Obfuscation Low None Medium
Anti-Fingerprinting Low Low (~50ms) Low
Honey Pot Low None Low
Method Tunneling Medium Low Medium
Timing Protection Medium Medium (~100ms) Low

Total added overhead: ~150ms per critical operation
Memory overhead: ~15KB for security code
Development time: ~2 weeks (with testing)


What's Next?

Current architecture is strong, but there's always room for improvement:

Planned additions:

  • Hardware secure element (ATECC608A) for key storage
  • Encrypted SD card backup with versioning
  • Multi-device sync via encrypted mesh protocol
  • U2F/FIDO2 support for hardware security keys

Testing improvements:

  • Automated penetration testing suite
  • Fuzzing for API endpoints
  • Load testing for timing attack resistance
  • Third-party security audit

Conclusion

Building secure embedded systems doesn't require enterprise budgets or closed-source magic. It requires:

  1. Understanding threat models (what attacks are likely?)
  2. Layered defenses (multiple independent protections)
  3. Testing and validation (verify it actually works)
  4. Open, auditable code (trust through transparency)

The 8-layer architecture in SecureGen demonstrates that even a $15 ESP32 device can implement defense-in-depth security rivaling commercial products.

The full source code is available on GitHub: github.com/makepkg/SecureGen

Every security decision, every line of code, every trade-off is documented and auditable. Because verifiable security is the only real security.


Resources


Questions? Comments? Drop them below! I'm happy to discuss specific layers, trade-offs, or alternative approaches.

Building something similar? I'd love to hear about your security architecture!

esp32 #security #embedded #architecture #defenseinдепth #encryption #opensource

📸 Images to Include

1. Cover Image

Your 8-layers infographic (already have)

2. Code Comparison Images

Create these in VS Code with dark theme, screenshot:

BEFORE:
GET /api/totp
GET /api/passwords
DELETE /api/passwords/1

AFTER:
POST /a7f3c9e8b2d4f1a6...
POST /9e4f1b8c3a7d2f5e...
POST /c3f7a9e1b5d8f2a4...

BEFORE:
Server: ESP-AsyncWebServer/1.2.3
X-ESP32-Chip: ESP32-D0WDQ6
Content-Type: application/json

AFTER:
X-k8f3a2: application/json
X-AspNetMvc-Version: 5.2.7
X-Powered-By: PHP/7.4.3
Enter fullscreen mode Exit fullscreen mode

3. Timing Attack Graph

Simple graph showing response times:

  • Without protection: clear pattern
  • With protection: random scatter

Simple graph showing response times:

  • Without protection: clear pattern
  • With protection: random scatter