ESP32 디지털 키 시스템
ESP32-S3 + 키 관리 서버 + Web UI로 CCC Digital Key의 차량 등록, 오너 페어링, 키 공유, 인수 흐름을 재현
개요
CCC(Car Connectivity Consortium) Digital Key의 실무 프로세스를 3계층으로 재현한 프로젝트다. 차량 출고 → 오너 페어링 → 일상 사용 → 키 공유 → 차량 인수(소유권 이전)까지의 전체 라이프사이클을 구현한다.
실제 CCC 생태계에서는 OEM 백엔드가 차량과 키를 중앙 관리하고, 스마트폰 앱이 BLE/NFC로 차량과 통신한다. 이 프로젝트에서는 각 역할을 다음과 같이 대응시킨다:
| 실제 CCC | 이 프로젝트 | 역할 |
|---|---|---|
| OEM 백엔드 | dk-server (FastAPI, :8100) | 계정 관리, 차량 등록, 키 생성, 클라우드 프로비저닝, 이벤트 감사 |
| 스마트폰 앱 | dk-web (FastAPI + WebSocket, :8000) | 세션 로그인, 자동 스캔, BLE 인증, 실시간 상태 모니터 |
| 차량 (SE + BLE/UWB) | ESP32-S3 (NimBLE + mbedTLS + WiFi) | 상호 인증, 클라우드 키 동기화, 근접 판정, 잠금 제어 |
구현한 것:
- 다중 계정 시스템 (이름 + PIN, dk-web 세션 로그인)
- 서버 측 ECC P-256 키쌍 생성 및 관리
- 클라우드 키 프로비저닝 (ESP32 WiFi → dk-server, 30초 폴링, mDNS)
- 디바이스 식별 키 (ESP32가 ECC 키쌍 생성, WiFi로 dk-server에 등록)
- ECC P-256 ECDSA 챌린지-응답 인증 (클라이언트 → 디바이스)
- 상호 인증 (디바이스 → 클라이언트, 디바이스 식별 키 기반)
- BLE 자동 스캔 + 등록 차량 자동 연결
- BLE RSSI 근접 감지 (5샘플 중앙값 필터 + 2초 히스테리시스)
- IMMEDIATE 존 자동 잠금해제 / 이탈 시 자동 잠금
- 서버 기반 계정 간 키 공유
- 인증 이벤트 감사 로그
- Web UI 실시간 상태 모니터 (WebSocket)
- WS2812 LED 상태 표시 / GPIO 0 팩토리 리셋
- Secure Boot V2 + Flash Encryption
구현하지 않은 것:
- SE(Secure Element) 하드웨어 키 저장
- UWB 정밀 측위
- 물리적 잠금장치 구동
- PKI / X.509 인증서 체인
시스템 아키텍처
┌───────────────────────────────────────────────────────────────┐
│ dk-server (:8100) │
│ OEM 백엔드 │
│ │
│ 계정 관리 · 차량 CRUD · 키 생성 │
│ 클라우드 프로비저닝 API · 이벤트 로그 · mDNS (dk-server.local) │
│ vehicles.json · events.jsonl │
└──────────────┬──────────────────────────────┬──────────────────┘
│ HTTP (REST API) │ HTTP (WiFi)
│ │
▼ ▼
┌──────────────────────┐ ┌──────────────────────────────┐
│ dk-web (:8000) │ │ ESP32-S3 (차량) │
│ 스마트폰 앱 │ │ │
│ │ BLE │ dk_auth · dk_keystore │
│ 세션 로그인 │◄─────►│ dk_cloud (30초 키 동기화) │
│ 자동 스캔 / 연결 │ │ dk_proximity · dk_lock │
│ 상호 인증 │ │ dk_wifi (mDNS) │
│ 상태 모니터 │ │ dk_led · dk_button │
│ │ │ ble_stack (NimBLE, 최대 3) │
└──────────────────────┘ └──────────────────────────────┘
dk-server가 양쪽에 키를 배포한다:
- 공개키 → ESP32: WiFi 폴링 (
GET /api/provision/{ble_addr}) - 비밀키 → dk-web: HTTP 다운로드 (
GET /api/vehicles/{id}/keys/{key_id}/private)
실무 프로세스 매핑
1) 차량 출고 — 디바이스 프로비저닝
실제: OEM이 공장에서 차량 SE에 인증서를 주입하고, 백엔드에 VIN을 등록한다.
이 프로젝트:
- ESP32-S3에 Secure Boot V2 + Flash Encryption 적용 펌웨어를 플래시한다
- dk-server에 차량을 등록한다 (이름 + BLE 주소)
- ESP32가 부팅 → WiFi 연결 → dk-server에 디바이스 식별 공개키를 등록한다
관리자 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..." } │
│ │─────────────────────►│
│ │ 차량에 저장 │
2) 오너 페어링 — 최초 키 등록
실제: 딜러십에서 오너가 스마트폰 앱으로 차량과 BLE 페어링. OEM 백엔드가 오너의 공개키를 차량에 바인딩한다.
이 프로젝트:
- dk-server에 오너 계정을 생성한다 (
POST /api/accounts) - 차량에 오너 키를 생성한다 (
POST /api/vehicles/{id}/keys+ 계정 이름) - dk-server가 ECC P-256 키쌍을 생성하고 공개키와 비밀키를 모두 저장한다
- ESP32가 WiFi로 dk-server를 폴링 → 오너 공개키 수신 → 자동 승인
- 오너가 dk-web에 로그인 → 자동 스캔 → BLE 연결 → 서버에서 받은 비밀키로 인증
dk-server ESP32-S3 dk-web
│ │ │
│ 키 생성 완료: │ │
│ { key_id, public_key, │ │
│ private_key_pem } │ │
│ │ │
│ GET /api/provision/{ble_addr} │ │
│◄─────────────────────────────────│ WiFi (30초 폴링) │
│ { keys: [{key_id, │ │
│ public_key}] } │ │
│─────────────────────────────────►│ │
│ │ NVS: 자동 승인 │
│ │ │
│ GET /.../keys/{kid}/private │ │
│◄────────────────────────────────────────────────────────│
│ { private_key: "-----BEGIN │ │
│ EC PRIVATE KEY-----..." } │ │
│────────────────────────────────────────────────────────►│
│ │ │
│ │ BLE 연결 │
│ │◄────────────────────│
│ │ 챌린지-응답 인증 │
│ │◄───────────────────►│
│ │ AUTH_OK │
│ POST /api/events │ │
│◄────────────────────────────────────────────────────────│
3) 일상 사용 — 인증 + 잠금해제
실제: 오너가 스마트폰을 들고 차량에 접근하면 BLE로 자동 인증 + UWB로 위치 확인 후 잠금해제.
이 프로젝트:
- dk-web이 등록 차량의 BLE 주소를 자동 스캔한다
- 발견 → 4단계 인증 파이프라인:
- Connect — BLE 연결 + disconnect 콜백 등록
- Auth — 챌린지-응답 인증 (클라이언트가 32바이트 챌린지 서명) + 이벤트 보고
- Device Auth — 디바이스 공개키를 읽고, dk-server 기록과 비교 후, 디바이스에 챌린지-응답으로 신원 검증
- Subscribe — System Status notify 구독 (200ms 주기)
- RSSI 기반 근접 존 판정 + 2초 히스테리시스
auth OK+zone == IMMEDIATE(2초 유지) → 자동 잠금해제, LED 초록- 이탈 (
zone != IMMEDIATE, 2초 유지) → 자동 잠금, LED 꺼짐
auth_state == DK_AUTH_OK
AND
zone == DK_ZONE_IMMEDIATE (RSSI > -35 dBm, 2초 유지)
↓
lock_state → UNLOCKED
LED 초록
zone != DK_ZONE_IMMEDIATE (2초 유지)
↓
lock_state → LOCKED
LED 꺼짐
4) 키 공유 — 게스트 접근
실제: 오너가 OEM 앱에서 친구를 초대. 백엔드가 게스트 공개키를 차량에 등록 허가. 게스트 폰이 차량과 페어링.
이 프로젝트:
- 오너가 dk-server에서 차량을 공유한다 (
POST /api/vehicles/{id}/share+ 게스트 계정) - dk-server가 게스트용 키쌍을 생성하고 차량에 바인딩한다
- ESP32가 WiFi로 dk-server를 폴링 → 게스트 공개키 수신 → 자동 승인
- 게스트가 dk-web에 로그인 → 자동 스캔 → BLE 연결 → 서버에서 받은 키로 인증
오너 dk-server ESP32-S3 게스트 dk-web
│ │ │ │
│ POST /share │ │ │
│ { account: "Guest" } │ │ │
│─────────────────────►│ │ │
│ │ 키쌍 생성 │ │
│ │ │ │
│ │ GET /provision/{addr} │ │
│ │◄───────────────────────│ WiFi 폴링 │
│ │ { keys: [..., guest] }│ │
│ │───────────────────────►│ │
│ │ 자동 승인 │ │
│ │ │ │
│ │ GET /.../private │ │
│ │◄───────────────────────────────────│
│ │ { private_key } │ │
│ │───────────────────────────────────►│
│ │ │ │
│ │ │ BLE 인증 │
│ │ │◄──────────│
│ │ │ AUTH_OK │
│ │ │──────────►│
5) 차량 인수 — 소유권 이전
실제: 중고차 판매 시 OEM 백엔드에서 기존 오너의 모든 키를 폐기. 차량 SE를 초기화. 새 오너가 재등록.
이 프로젝트:
- dk-server에서 기존 차량의 키 바인딩을 모두 삭제한다
- ESP32 팩토리 리셋 (GPIO 0 3초 누름 → NVS 전체 삭제)
- dk-server에 새 오너를 등록한다
- 새 오너의 dk-web이 오너 페어링을 수행한다
기존 오너 ESP32-S3 dk-server
│ │ │
│ │ GPIO 0 3초 누름 │
│ │ → DkKeystore_EraseAll │
│ │ → LED 노란색 → 파란색 │
│ │ → 잠금 상태 복귀 │
│ │ │
│ DELETE /api/vehicles/{id}/keys/{key_id} │
│───────────────────────────────────────────────────►│
새 오너는 2) 오너 페어링부터 다시 시작한다.
6) 이벤트 감사
실제: OEM 백엔드가 모든 인증 시도를 기록. 보안 사고 시 추적에 사용.
이 프로젝트: dk-web이 인증 결과를 dk-server에 fire-and-forget으로 보고한다.
dk-web dk-server
│ │
│ POST /api/events │
│ { vehicle: "AA:BB:...", │
│ key_id: "abc123...", │
│ event: "auth", │
│ detail: "ok" } │
│───────────────────────────────────►│
│ │ events.jsonl에 append
GET /api/events?limit=50으로 최근 이벤트를 조회한다.
dk-server — 키 관리 서버
OEM 백엔드 역할. 내부 서비스 — 인증 미들웨어 없음. 계정 관리, 차량 등록, 키 생성, 클라우드 프로비저닝, 이벤트 감사를 담당한다.
dk-server # 첫 실행 시 계정 생성
dk-server --reset-pin # PIN 재설정
0.0.0.0:8100에서 실행. mDNS로 dk-server.local로 광고한다.
REST API:
| Method | Path | 설명 |
|---|---|---|
| POST | /api/login | PIN 인증 → 계정 이름 반환 |
| GET | /api/accounts | 계정 목록 |
| POST | /api/accounts | 계정 생성 (name + pin) |
| DELETE | /api/accounts/{name} | 계정 삭제 |
| GET | /api/vehicles | 차량 목록 (필터: account) |
| POST | /api/vehicles | 차량 등록 (name + ble_address) |
| DELETE | /api/vehicles/{id} | 차량 삭제 |
| PATCH | /api/vehicles/{id} | 차량 필드 업데이트 |
| POST | /api/vehicles/{id}/keys | 키 생성 (서버가 키쌍 생성) |
| GET | /api/vehicles/{id}/keys/{key_id}/private | 비밀키 PEM 다운로드 |
| DELETE | /api/vehicles/{id}/keys/{key_id} | 키 삭제 |
| POST | /api/vehicles/{id}/share | 다른 계정에 차량 공유 |
| GET | /api/accounts/{name}/keys | 계정의 전체 키 목록 |
| GET | /api/provision/{ble_addr} | 차량의 공개키 목록 (ESP32 WiFi) |
| POST | /api/provision/{ble_addr}/device-key | 디바이스 공개키 등록 (ESP32 WiFi) |
| GET | /api/events?limit=50 | 이벤트 로그 조회 |
| POST | /api/events | 이벤트 기록 (dk-web이 호출) |
| GET | /api/local-keys | 로컬 PEM 키 목록 |
인증: 다중 계정. 각 계정은 이름 + PIN (salt + SHA-256). ~/.dk-client/pin.json에 저장.
스토리지:
~/.dk-client/vehicles.json— 차량 + 키 바인딩 (공개키 + 비밀키)~/.dk-client/events.jsonl— append-only 이벤트 로그~/.dk-client/pin.json— 계정 인증 정보
dk-web — Web UI
스마트폰 앱 역할. 세션 기반 로그인, BLE 자동 스캔, 상호 디바이스 인증, WebSocket 실시간 모니터를 제공한다.
dk-web # 127.0.0.1:8000, 브라우저 자동 열림
로그인이 필요하다. dk-web에서 입력한 PIN은 dk-server로 프록시되어 인증된다. 세션은 쿠키(dk_web_session)로 유지된다.
연결 흐름 (4단계):
- Connect — BLE 연결 + disconnect 콜백 등록
- Auth — dk-server에서 비밀키 다운로드 → 챌린지-응답 인증 → 이벤트 보고
- Device Auth — ESP32에서 디바이스 공개키 읽기, dk-server 기록과 비교, 디바이스에 챌린지-응답으로 신원 검증
- Subscribe — System Status notify 구독 → 실시간 상태 표시
WebSocket 메시지 (클라이언트 → 서버):
| type | 설명 |
|---|---|
disconnect | BLE 연결 해제 |
approve | pending 키 승인 (key_id 지정) |
delete | 키 삭제 (key_id 지정) |
WebSocket 메시지 (서버 → 클라이언트):
| type | 설명 |
|---|---|
init | 초기 상태 (client_id, account, 등록 차량 목록) |
status | 실시간 시스템 상태 (auth, zone, rssi, lock, keys) |
auto_scan | 스캔 진행 상태 (scanning / found / not_found / no_vehicles) |
connect_progress | 4단계 연결 진행 (connect / auth / device_auth / subscribe) |
auth_result | 인증 결과 (success, state) |
log | 타임스탬프 로그 (INFO/WARNING/ERROR) |
command_result | 키 관리 명령 결과 |
펌웨어 모듈
dk_auth — 커넥션별 인증 + 디바이스 식별
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;
클라이언트 인증 챌린지: esp_fill_random(32). 검증: mbedtls_pk_verify (ECDSA-SHA256, P-256).
디바이스 식별: 첫 부팅 시 ECC P-256 키쌍을 생성하고 NVS에 저장한다. 상호 인증에 사용 — 디바이스가 DkAuth_SignChallenge()로 클라이언트의 챌린지에 서명한다. 공개키는 GATT (0x08)로 노출하고, WiFi로 dk-server에 등록한다.
dk_keystore — NVS 키 저장
typedef struct {
char key_id[16];
uint8_t pubkey[65]; // uncompressed P-256
uint8_t pubkey_len;
bool pending;
} dk_stored_key_t;
최대 8개. 첫 번째 키 자동 승인(오너). 클라우드 프로비저닝 키 자동 승인. DkKeystore_EraseAll()로 팩토리 리셋.
dk_proximity — RSSI 근접 판정
#define DK_RSSI_THRESHOLD_FAR (-55)
#define DK_RSSI_THRESHOLD_IMMEDIATE (-35)
#define DK_ZONE_HOLD_TICKS 10 // 200ms 폴링 × 10 = 2초
존: NONE(0) / FAR(1) / NEAR(2) / IMMEDIATE(3). 5샘플 중앙값 필터 + 히스테리시스.
자동 잠금해제: auth OK + IMMEDIATE 존 + 2초 유지 → 잠금해제.
자동 잠금: auth OK + IMMEDIATE 아닌 존 + 2초 유지 → 잠금.
dk_wifi — WiFi STA
ESP32 WiFi 스테이션 모드 (2.4 GHz). mDNS로 dk-server.local을 해석한다. 연결 실패 시 지수 백오프 (1초 → 최대 30초). SSID를 비워두면 WiFi/클라우드를 비활성화한다.
dk_cloud — 클라우드 키 동기화
FreeRTOS 태스크. WiFi 연결을 기다린 후:
- 디바이스 식별 공개키를 dk-server에 등록한다 (
POST /api/provision/{ble_addr}/device-key) - 30초 주기로 dk-server를 폴링한다 (
GET /api/provision/{ble_addr}) - 새 공개키를 NVS에 추가하고 자동 승인한다
기타 모듈
dk_service— 9개 GATT characteristic + 200ms status notify timerdk_lock— 잠금 상태 관리dk_led— WS2812 GPIO 48 (잠금=OFF, 해제=초록)dk_button— GPIO 0: 3초 → 팩토리 리셋 (누르는 중 LED 노란색 → 완료 시 파란색)ble_stack— NimBLE, “DK-XXXX” advertising, 최대 3 동시 연결
GATT 서비스
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=잠금, 0x01=해제
├── 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바이트 챌린지
Read: DER 서명
System Status 패킷
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
패키지 구조
firmware/
├── main/
│ ├── main.c
│ ├── dk_auth.{h,c} # 인증 상태 + 디바이스 식별 키
│ ├── dk_service.{h,c} # GATT 서비스 (9개 characteristic)
│ ├── dk_keystore.{h,c} # NVS 키 저장
│ ├── dk_lock.{h,c} # 잠금 상태
│ ├── dk_proximity.{h,c} # RSSI 존 판정
│ ├── dk_wifi.{h,c} # WiFi STA + mDNS
│ ├── dk_cloud.{h,c} # 클라우드 키 동기화 (30초 폴링)
│ ├── dk_led.{h,c} # WS2812 LED
│ └── dk_button.{h,c} # GPIO 0 팩토리 리셋
├── components/
│ └── ble_stack/ # NimBLE 래퍼
├── sdkconfig.defaults
└── partitions.csv
client/
├── pyproject.toml
└── dk_client/
├── main.py # CLI (dk-client)
├── ble.py # BLE 클라이언트 (bleak)
├── crypto.py # ECC P-256 키 생성/서명/검증
├── protocol.py # GATT UUID, SystemStatus 파싱
├── server/
│ ├── __init__.py # dk-web, dk-server 엔트리포인트
│ ├── app.py # dk-web FastAPI (세션 로그인)
│ ├── websocket.py # WebSocket + BLE + 자동 스캔
│ ├── reg_app.py # dk-server FastAPI (내부 API)
│ ├── auth.py # 계정 관리 (PIN + salt)
│ └── store.py # JSON 파일 스토리지 + 키쌍 생성
└── static/
├── index.html # Web UI
├── dashboard.html # 서버 대시보드
├── login.html # PIN 로그인
├── css/style.css
└── js/
├── app.js # Web UI WebSocket 클라이언트
└── dashboard.js # 대시보드 API 클라이언트
CLI 명령:
dk-client scan|provision|auth|unlock|lock|status|approve|delete
dk-web # Web UI (:8000)
dk-server # 키 관리 서버 (:8100)
dk-server --reset-pin # PIN 재설정
빌드 시스템
Docker 컨테이너(espressif/idf:v5.4.1). dkidf 스크립트.
dkidf build # 빌드 + 파티션 서명
dkidf flash -p /dev/ttyACM0 # 암호화 플래시
dkidf flash --full -p /dev/ttyACM0 # 전체 플래시
dkidf monitor -p /dev/ttyACM0 # 시리얼 모니터
Secure Boot V2 + Flash Encryption (AES-256).
보안 한계
| 한계 | 실제 CCC 대비 | 영향 |
|---|---|---|
| 소프트웨어 키 저장 (NVS) | SE | Flash Encryption으로 보호하지만, 런타임 메모리에서 추출 가능 |
| BLE RSSI 근접 | UWB ToF | 릴레이 어택 방어 불가 |
| BLE 평문 전송 | 암호화 채널 | GATT 레이어 암호화 미적용 |
| Raw 공개키 | X.509 인증서 | 인증서 체인이나 PKI 인프라 없음 |
| JSON 파일 스토리지 | RDBMS + HSM | 서버 키 관리가 파일 기반 |
| PIN 인증 | OAuth + mTLS | 서버 접근 제어가 단순 |