ESP32 Digital Key System
Reproducing CCC Digital Key vehicle registration, owner pairing, key sharing, and handover flows with ESP32-S3 + key management server + Web UI
Overview
A 3-tier project that reproduces the real-world CCC (Car Connectivity Consortium) Digital Key processes: vehicle production → owner pairing → daily use → key sharing → vehicle handover (ownership transfer).
In the actual CCC ecosystem, the OEM backend centrally manages vehicles and keys, while the smartphone app communicates with the vehicle via BLE/NFC. This project maps each role as follows:
| Actual CCC | This Project | Role |
|---|---|---|
| OEM Backend | dk-server (FastAPI, :8100) | Account management, vehicle registration, key generation, cloud provisioning, event audit |
| Smartphone App | dk-web (FastAPI + WebSocket, :8000) | Session login, auto-scan, BLE auth, real-time status monitor |
| Vehicle (SE + BLE/UWB) | ESP32-S3 (NimBLE + mbedTLS + WiFi) | Mutual auth, cloud key sync, proximity detection, lock control |
What’s implemented:
- Multi-account system (name + PIN, dk-web session login)
- Server-side ECC P-256 keypair generation and management
- Cloud key provisioning (ESP32 WiFi → dk-server, 30s polling, mDNS)
- Device identity key (ESP32 generates ECC keypair, registers to dk-server via WiFi)
- ECC P-256 ECDSA challenge-response authentication (client → device)
- Mutual authentication (device → client via device identity key)
- BLE auto-scan + auto-connect to registered vehicles
- BLE RSSI proximity detection (5-sample median filter + 2s hysteresis)
- Auto-unlock at IMMEDIATE zone / auto-lock on departure
- Server-based key sharing between accounts
- Authentication event audit log
- Web UI real-time status monitor (WebSocket)
- WS2812 LED status indication / GPIO 0 factory reset
- Secure Boot V2 + Flash Encryption
Out of scope:
- SE (Secure Element) hardware key storage
- UWB precise ranging
- Physical lock actuator
- PKI / X.509 certificate chain
System Architecture
┌───────────────────────────────────────────────────────────────┐
│ dk-server (:8100) │
│ OEM Backend │
│ │
│ Account mgmt · Vehicle CRUD · Key generation │
│ Cloud provision API · Event log · mDNS (dk-server.local) │
│ vehicles.json · events.jsonl │
└──────────────┬──────────────────────────────┬──────────────────┘
│ HTTP (REST API) │ HTTP (WiFi)
│ │
▼ ▼
┌──────────────────────┐ ┌──────────────────────────────┐
│ dk-web (:8000) │ │ ESP32-S3 (Vehicle) │
│ Smartphone App │ │ │
│ │ BLE │ dk_auth · dk_keystore │
│ Session login │◄─────►│ dk_cloud (30s key sync) │
│ Auto-scan / connect │ │ dk_proximity · dk_lock │
│ Mutual auth │ │ dk_wifi (mDNS) │
│ Status monitor │ │ dk_led · dk_button │
│ │ │ ble_stack (NimBLE, max 3) │
└──────────────────────┘ └──────────────────────────────┘
dk-server distributes keys to both sides:
- Public key → ESP32 via WiFi polling (
GET /api/provision/{ble_addr}) - Private key → dk-web via HTTP download (
GET /api/vehicles/{id}/keys/{key_id}/private)
Real-World Process Mapping
1) Vehicle Production — Device Provisioning
Real: OEM injects certificates into the vehicle SE at factory and registers VIN in the backend.
This project:
- Flash ESP32-S3 with Secure Boot V2 + Flash Encryption firmware
- Register vehicle in dk-server (name + BLE address)
- ESP32 boots → WiFi connect → registers device identity public key to dk-server
Admin dk-server ESP32-S3
│ │ │
│ POST /api/vehicles │ │
│ { name, ble_address } │ │
│──────────────────────────────►│ │
│ { id: "a1b2c3d4", ... } │ │
│◄──────────────────────────────│ │
│ │ │
│ │ WiFi (mDNS) │
│ │◄─────────────────────│
│ │ POST /api/provision │
│ │ /{ble_addr}/device- │
│ │ key │
│ │ { device_public_key │
│ │ : "04ab..." } │
│ │─────────────────────►│
│ │ stored in vehicle │
2) Owner Pairing — Initial Key Registration
Real: Owner pairs smartphone with vehicle via BLE at dealership. OEM backend binds the owner’s public key to the vehicle.
This project:
- Create owner account in dk-server (
POST /api/accounts) - Create owner key for vehicle (
POST /api/vehicles/{id}/keyswith account name) - dk-server generates ECC P-256 keypair, stores both public and private keys
- ESP32 WiFi polls dk-server → receives owner’s public key → auto-approves
- Owner logs into dk-web → auto-scan → BLE connect → auth with server-provided private key
dk-server ESP32-S3 dk-web
│ │ │
│ Key generated: │ │
│ { key_id, public_key, │ │
│ private_key_pem } │ │
│ │ │
│ GET /api/provision/{ble_addr} │ │
│◄─────────────────────────────────│ WiFi (30s poll) │
│ { keys: [{key_id, │ │
│ public_key}] } │ │
│─────────────────────────────────►│ │
│ │ NVS: auto-approve │
│ │ │
│ GET /.../keys/{kid}/private │ │
│◄────────────────────────────────────────────────────────│
│ { private_key: "-----BEGIN │ │
│ EC PRIVATE KEY-----..." } │ │
│────────────────────────────────────────────────────────►│
│ │ │
│ │ BLE connect │
│ │◄────────────────────│
│ │ Challenge-response │
│ │◄───────────────────►│
│ │ AUTH_OK │
│ POST /api/events │ │
│◄────────────────────────────────────────────────────────│
3) Daily Use — Authentication + Unlock
Real: Owner approaches vehicle with smartphone, auto-authenticates via BLE + confirms position with UWB, then unlocks.
This project:
- dk-web auto-scans BLE for registered vehicles (matches BLE address)
- Found → 4-step authentication pipeline:
- Connect — BLE connection + disconnect callback
- Auth — Challenge-response (client signs 32-byte challenge) + event report
- Device Auth — Read device pubkey, verify against dk-server record, challenge-response to device
- Subscribe — System Status notify (200ms interval)
- RSSI-based zone detection + 2s hysteresis
auth OK+zone == IMMEDIATE(stable 2s) → auto-unlock, LED green- Departure (
zone != IMMEDIATE, stable 2s) → auto-lock, LED off
auth_state == DK_AUTH_OK
AND
zone == DK_ZONE_IMMEDIATE (RSSI > -35 dBm, stable 2s)
↓
lock_state → UNLOCKED
LED green
zone != DK_ZONE_IMMEDIATE (stable 2s)
↓
lock_state → LOCKED
LED off
4) Key Sharing — Guest Access
Real: Owner invites friend via OEM app. Backend authorizes guest’s public key for the vehicle. Guest’s phone pairs with vehicle.
This project:
- Owner shares vehicle via dk-server (
POST /api/vehicles/{id}/sharewith guest account name) - dk-server generates new keypair for guest, binds to vehicle
- ESP32 WiFi polls dk-server → receives guest’s public key → auto-approves
- Guest logs into dk-web → auto-scan → BLE connect → auth with server-provided key
Owner dk-server ESP32-S3 Guest dk-web
│ │ │ │
│ POST /share │ │ │
│ { account: "Guest" } │ │ │
│─────────────────────►│ │ │
│ │ keypair generated │ │
│ │ │ │
│ │ GET /provision/{addr} │ │
│ │◄───────────────────────│ WiFi poll │
│ │ { keys: [..., guest] }│ │
│ │───────────────────────►│ │
│ │ auto-approved │ │
│ │ │ │
│ │ GET /.../private │ │
│ │◄───────────────────────────────────│
│ │ { private_key } │ │
│ │───────────────────────────────────►│
│ │ │ │
│ │ │ BLE auth │
│ │ │◄──────────│
│ │ │ AUTH_OK │
│ │ │──────────►│
5) Vehicle Handover — Ownership Transfer
Real: On resale, OEM backend revokes all existing keys. Vehicle SE is reset. New owner re-registers.
This project:
- Delete all key bindings from dk-server
- Factory reset ESP32 (GPIO 0 long press 3s → NVS erase all)
- Register new owner in dk-server
- New owner performs owner pairing from step 2
Previous Owner ESP32-S3 dk-server
│ │ │
│ │ GPIO 0 long press 3s │
│ │ → DkKeystore_EraseAll │
│ │ → LED yellow → blue │
│ │ → return to locked │
│ │ │
│ DELETE /api/vehicles/{id}/keys/{key_id} │
│───────────────────────────────────────────────────►│
New owner starts from step 2 (Owner Pairing).
6) Event Audit
Real: OEM backend logs all authentication attempts for security incident tracking.
This project: dk-web reports auth results to dk-server (fire-and-forget).
dk-web dk-server
│ │
│ POST /api/events │
│ { vehicle: "AA:BB:...", │
│ key_id: "abc123...", │
│ event: "auth", │
│ detail: "ok" } │
│───────────────────────────────────►│
│ │ appended to events.jsonl
GET /api/events?limit=50 retrieves recent events.
dk-server — Key Management Server
OEM backend role. Internal service — no auth middleware. Manages accounts, vehicle registration, key generation, cloud provisioning, and event audit.
dk-server # First run prompts account creation
dk-server --reset-pin # Reset PIN
Runs on 0.0.0.0:8100. Advertises via mDNS as dk-server.local.
REST API:
| Method | Path | Description |
|---|---|---|
| POST | /api/login | PIN verification → returns account name |
| GET | /api/accounts | List accounts |
| POST | /api/accounts | Create account (name + pin) |
| DELETE | /api/accounts/{name} | Delete account |
| GET | /api/vehicles | List vehicles (filter: account) |
| POST | /api/vehicles | Register vehicle (name + ble_address) |
| DELETE | /api/vehicles/{id} | Delete vehicle |
| PATCH | /api/vehicles/{id} | Update vehicle fields |
| POST | /api/vehicles/{id}/keys | Create key (server generates keypair) |
| GET | /api/vehicles/{id}/keys/{key_id}/private | Download private key PEM |
| DELETE | /api/vehicles/{id}/keys/{key_id} | Delete key |
| POST | /api/vehicles/{id}/share | Share vehicle with another account |
| GET | /api/accounts/{name}/keys | List all keys for account |
| GET | /api/provision/{ble_addr} | Get public keys for vehicle (ESP32 WiFi) |
| POST | /api/provision/{ble_addr}/device-key | Register device public key (ESP32 WiFi) |
| GET | /api/events?limit=50 | Query event log |
| POST | /api/events | Record event (dk-web calls) |
| GET | /api/local-keys | List local PEM key files |
Auth: Multiple accounts. Each account has name + PIN (salt + SHA-256). Stored in ~/.dk-client/pin.json.
Storage:
~/.dk-client/vehicles.json— vehicles + key bindings (public + private keys)~/.dk-client/events.jsonl— append-only event log~/.dk-client/pin.json— account credentials
dk-web — Web UI
Smartphone app role. Provides session-based login, BLE auto-scan, authentication with mutual device verification, and real-time monitoring via WebSocket.
dk-web # 127.0.0.1:8000, auto-opens browser
Requires login. PIN entered on dk-web is proxied to dk-server for verification. Session is maintained via cookie (dk_web_session).
Connection flow (4 steps):
- Connect — BLE connect + disconnect callback registration
- Auth — Fetch private key from dk-server → challenge-response → report event
- Device Auth — Read device pubkey from ESP32, verify against dk-server record, challenge-response to verify device holds the private key
- Subscribe — System Status notify subscription → real-time display
WebSocket messages (client → server):
| type | Description |
|---|---|
disconnect | Terminate BLE connection |
approve | Approve pending key (by key_id) |
delete | Delete key (by key_id) |
WebSocket messages (server → client):
| type | Description |
|---|---|
init | Initial state (client_id, account, registered vehicles) |
status | Real-time system status (auth, zone, rssi, lock, keys) |
auto_scan | Scan progress (scanning / found / not_found / no_vehicles) |
connect_progress | 4-step progress (connect / auth / device_auth / subscribe) |
auth_result | Auth result (success, state) |
log | Timestamped log (INFO/WARNING/ERROR) |
command_result | Key management command result |
Firmware Modules
dk_auth — Per-connection Auth + Device Identity
typedef enum {
DK_AUTH_DISCONNECTED = 0,
DK_AUTH_CONNECTED = 1,
DK_AUTH_CHALLENGE = 2,
DK_AUTH_OK = 3,
DK_AUTH_FAILED = 4,
} dk_auth_state_t;
typedef struct {
uint16_t conn_handle;
dk_auth_state_t auth_state;
uint8_t challenge[32];
char key_id[16];
int8_t rssi_buf[5];
uint8_t rssi_idx;
bool status_subscribed;
bool auth_subscribed;
uint8_t prev_zone;
uint8_t zone_hold_count;
uint8_t device_challenge[32];
bool device_challenge_valid;
} dk_conn_state_t;
Client auth challenge: esp_fill_random(32). Verification: mbedtls_pk_verify (ECDSA-SHA256, P-256).
Device identity: ECC P-256 keypair generated on first boot, stored in NVS. Used for mutual authentication — device signs challenges from clients with DkAuth_SignChallenge(). Public key exposed via GATT (0x08) and registered to dk-server via WiFi.
dk_keystore — NVS Key Storage
typedef struct {
char key_id[16];
uint8_t pubkey[65]; // uncompressed P-256
uint8_t pubkey_len;
bool pending;
} dk_stored_key_t;
Max 8 keys. First key auto-approved (owner). Cloud-provisioned keys auto-approved. DkKeystore_EraseAll() for factory reset.
dk_proximity — RSSI Proximity Detection
#define DK_RSSI_THRESHOLD_FAR (-55)
#define DK_RSSI_THRESHOLD_IMMEDIATE (-35)
#define DK_ZONE_HOLD_TICKS 10 // 2s at 200ms poll
Zones: NONE(0) / FAR(1) / NEAR(2) / IMMEDIATE(3). 5-sample median filter + hysteresis.
Auto-unlock: auth OK + IMMEDIATE zone + stable 2s → unlock.
Auto-lock: auth OK + not IMMEDIATE + stable 2s → lock.
dk_wifi — WiFi STA
ESP32 WiFi station mode (2.4 GHz). mDNS for resolving dk-server.local. Exponential backoff on connection failure (1s → 30s max). Optional — leave SSID empty to disable WiFi/cloud entirely.
dk_cloud — Cloud Key Sync
FreeRTOS task. Waits for WiFi connection, then:
- Registers device identity public key to dk-server (
POST /api/provision/{ble_addr}/device-key) - Polls dk-server every 30s (
GET /api/provision/{ble_addr}) - Adds new public keys to NVS, auto-approves
Other Modules
dk_service— 9 GATT characteristics + 200ms status notify timerdk_lock— Lock state managementdk_led— WS2812 GPIO 48 (locked=OFF, unlocked=green)dk_button— GPIO 0: long press 3s → factory reset (LED yellow while held → blue on complete)ble_stack— NimBLE, “DK-XXXX” advertising, up to 3 simultaneous connections
GATT Service
Service: Digital Key (UUID: 12345678-1234-1234-1234-123456789abc)
│
├── Auth State (0x01) [Read | Notify] per-connection (0~4)
├── Challenge (0x02) [Read] 32 bytes, per-connection
├── Response (0x03) [Write] key_id(16) + DER sig
├── Provision (0x04) [Write] key_id(16) + pubkey(65)
├── Lock Command (0x05) [Write] 0x00=lock, 0x01=unlock
├── System Status (0x06) [Read | Notify] 8 bytes, 200ms
├── Key Management (0x07) [Write] cmd(1) + key_id(16)
├── Device Pubkey (0x08) [Read] 65 bytes, uncompressed P-256
└── Device Auth (0x09) [Read | Write] Write: 32-byte challenge
Read: DER signature
System Status Packet
typedef struct __attribute__((packed)) {
uint8_t auth_state;
uint8_t zone_level;
int8_t rssi;
uint8_t lock_state;
uint8_t registered_keys;
uint8_t pending_keys;
uint8_t reserved[2];
} system_status_t; // 8 bytes, little-endian
Package Structure
firmware/
├── main/
│ ├── main.c
│ ├── dk_auth.{h,c} # Auth state + device identity key
│ ├── dk_service.{h,c} # GATT service (9 characteristics)
│ ├── dk_keystore.{h,c} # NVS key storage
│ ├── dk_lock.{h,c} # Lock state
│ ├── dk_proximity.{h,c} # RSSI zone detection
│ ├── dk_wifi.{h,c} # WiFi STA + mDNS
│ ├── dk_cloud.{h,c} # Cloud key sync (30s poll)
│ ├── dk_led.{h,c} # WS2812 LED
│ └── dk_button.{h,c} # GPIO 0 factory reset
├── components/
│ └── ble_stack/ # NimBLE wrapper
├── sdkconfig.defaults
└── partitions.csv
client/
├── pyproject.toml
└── dk_client/
├── main.py # CLI (dk-client)
├── ble.py # BLE client (bleak)
├── crypto.py # ECC P-256 key gen/sign/verify
├── protocol.py # GATT UUIDs, SystemStatus parsing
├── server/
│ ├── __init__.py # dk-web, dk-server entry points
│ ├── app.py # dk-web FastAPI (session login)
│ ├── websocket.py # WebSocket + BLE + auto-scan
│ ├── reg_app.py # dk-server FastAPI (internal API)
│ ├── auth.py # Account management (PIN + salt)
│ └── store.py # JSON file storage + keypair gen
└── static/
├── index.html # Web UI
├── dashboard.html # Server dashboard
├── login.html # PIN login
├── css/style.css
└── js/
├── app.js # Web UI WebSocket client
└── dashboard.js # Dashboard API client
CLI commands:
dk-client scan|provision|auth|unlock|lock|status|approve|delete
dk-web # Web UI (:8000)
dk-server # Key management server (:8100)
dk-server --reset-pin # Reset PIN
Build System
Docker container (espressif/idf:v5.4.1). dkidf script.
dkidf build # build + sign partition table
dkidf flash -p /dev/ttyACM0 # encrypted flash
dkidf flash --full -p /dev/ttyACM0 # full flash
dkidf monitor -p /dev/ttyACM0 # serial monitor
Secure Boot V2 + Flash Encryption (AES-256).
Security Limitations
| Limitation | vs. Actual CCC | Impact |
|---|---|---|
| Software key storage (NVS) | SE | Protected by flash encryption, but extractable from runtime memory |
| BLE RSSI proximity | UWB ToF | No relay attack defense |
| BLE plaintext transport | Encrypted channel | No GATT-layer encryption |
| Raw public keys | X.509 certificates | No certificate chain or PKI infrastructure |
| JSON file storage | RDBMS + HSM | Server key management is file-based |
| PIN authentication | OAuth + mTLS | Server access control is simple |