ESP32-S3 디바이스 프로비저닝 시스템
양산 전체에서 벽돌 0대를 달성한 6단계 멱등 프로비저닝 파이프라인 - eFuse 보안, SHA256 기반 시리얼 생성, AWS IoT 자동 통합
배경
- 시스템: ESP32-S3 기반 로봇 디바이스 (Ceily 천장 로봇, Wally 벽면 로봇)
- 요구사항: 양산을 위한 보안성 있고 반복 가능한 공장 프로비저닝
- 제약: 각 기기마다 고유 식별자, 암호화된 펌웨어, 클라우드 연결 필요
핵심 문제
양산 IoT 기기의 보안 프로비저닝에 필요한 것:
- 일회성 프로그래밍 가능 eFuse에 보안 키 버닝
- 기기를 벽돌 만들지 않고 Secure Boot와 Flash Encryption 활성화
- 하드웨어 특성에서 고유한 디바이스 식별자 생성
- 클라우드 연결을 위한 AWS IoT 인증서 프로비저닝
어려운 점: eFuse 버닝은 돌이킬 수 없습니다. 시퀀스나 설정에서 실수 하나면 기기가 영구적으로 벽돌이 됩니다. 숙련도가 다양한 작업자가 있는 공장 환경에서 수천 대에 걸쳐 안정적으로 동작해야 합니다.
핵심 아이디어
프로비저닝을 6개의 독립적이고 멱등한 단계로 설계합니다. 각 단계는 실행 전 전제조건을 검증하고 안전하게 재시도할 수 있습니다.
왜 6단계인가? 각 단계마다 실패 모드와 복구 전략이 다릅니다:
| 단계 | 실패 모드 | 복구 |
|---|---|---|
| 1. Security eFuse | 잘못된 키 버닝 | 기기 폐기 (돌이킬 수 없음) |
| 2. Factory 펌웨어 | 플래시 손상 | S3에서 재플래싱 |
| 3. 시리얼 프로비저닝 | 통신 오류 | 재시도 (eFuse가 중복 감지) |
| 4. AWS IoT 설정 | 네트워크 실패 | 재시도 (AWS가 기존 thing 감지) |
| 5. 인증서 플래싱 | 전원 손실 | 로컬 캐시에서 재플래싱 |
| 6. 프로덕션 펌웨어 | 모든 오류 | S3에서 재플래싱 |
묶으면 세분화된 복구를 잃습니다. 더 나누면 불필요한 복잡성이 추가됩니다.
접근 방식
1) 플래시 메모리 레이아웃
ESP32-S3 Flash (8MB)
+------------------+ 0x00000000
| Bootloader | 서명됨, 암호화됨
+------------------+ 0x00010000
| Partition Table | 서명됨
+------------------+ 0x0001A000
| AWS Certificates| 16KB, 암호화됨
+------------------+ 0x00020000
| Application | 서명됨, 암호화됨
+------------------+ 0x00800000
eFuse 레이아웃:
BLOCK_KEY0: Secure Boot digest (32 bytes)
BLOCK_KEY1: XTS-AES-256 key part 1 (16 bytes)
BLOCK_KEY2: XTS-AES-256 key part 2 (16 bytes)
BLOCK3: Custom MAC / Serial (8 bytes)
2) 보안 프로비저닝 (eFuse 버닝)
ESP32-S3 Secure Boot V2와 XTS-AES-256 Flash Encryption 설정:
def generate_commands(self, mode):
commands = []
# Secure Boot 키 다이제스트 → BLOCK_KEY0
if not self.efuse_status.get("key0_burned"):
commands.append({
"cmd": ["espefuse", "burn_key", "BLOCK_KEY0",
"secure_boot_key_digest.bin", "SECURE_BOOT_DIGEST0"],
})
# XTS-AES-256은 두 개의 키 블록 필요 (ESP32-S3 전용)
if not self.efuse_status.get("key1_burned"):
commands.append({
"cmd": ["espefuse", "burn_key", "BLOCK_KEY1",
"flash_encryption_key.bin", "XTS_AES_256_KEY_1"],
})
if not self.efuse_status.get("key2_burned"):
commands.append({
"cmd": ["espefuse", "burn_key", "BLOCK_KEY2",
"flash_encryption_key.bin", "XTS_AES_256_KEY_2"],
})
# 보안 기능 활성화
commands.append(["burn_efuse", "SECURE_BOOT_EN", "1"])
commands.append(["burn_efuse", "SPI_BOOT_CRYPT_CNT", "1"])
return commands
왜 XTS-AES-256인가? ESP32-S3 하드웨어 암호화는 256비트 키를 두 eFuse 블록에 분할해야 합니다. 단일 블록 AES-128은 더 약하고 양산에 권장되지 않습니다.
3) 시리얼 번호 생성
SHA256을 사용해 하드웨어 MAC 주소에서 결정론적 시리얼 생성:
class FactoryProvisioner:
FACTORY_SECRET = os.environ["FACTORY_SECRET"] # 보안 저장소에서 로드
def generate_hash64_id(self, wifi_mac):
# 조합: WiFi MAC (6 bytes) + 공장 시크릿
combined = wifi_mac + self.FACTORY_SECRET.encode("utf-8")
# SHA256 → 첫 8바이트로 64비트 ID 생성
hash_bytes = hashlib.sha256(combined).digest()
hash64 = hash_bytes[:8]
return hash64.hex() # 16자 hex: "4c694f4b2e2cc7b2"
왜 원시 MAC 대신 해시인가?
- MAC 주소는 스푸핑 가능; 시크릿과 해시는 위조 불가
- 같은 MAC은 항상 같은 시리얼 생성 (결정론적)
- 64비트 충돌 확률: 2^64 분의 1 (~1800경)
4) 인증서 파티션 구조
전용 16KB 파티션에 인증서 저장:
CERT_MAGIC = 0x41575343 # 리틀 엔디안 "AWSC"
CERT_VERSION = 1
PARTITION_OFFSET = 0x1A000
PARTITION_SIZE = 0x4000 # 16KB
# 파티션 구조:
# +0x000: Magic (4B) + Version (4B)
# +0x008: Cert offset (4B) + Cert length (4B)
# +0x010: Key offset (4B) + Key length (4B)
# +0x018: CA offset (4B) + CA length (4B)
# +0x020: Reserved (8B)
# +0x028: Endpoint URL (128B, null 종료)
# +0x0A8: Device certificate (~1.2KB)
# +0x????: Private key (~1.7KB)
# +0x????: CA certificate (~1.2KB)
왜 별도 파티션인가? 인증서는 총 ~4KB입니다. 앱 바이너리에 포함하면 기기마다 펌웨어를 다시 빌드해야 합니다. 별도 파티션은 인증서만 업데이트할 수 있게 합니다.
5) 멱등한 오케스트레이션
각 단계가 실행 전 완료 여부 확인:
class FullProvisioner:
TOTAL_STEPS = 6
def step1_security_provision(self):
# 확인: eFuse가 이미 버닝되었나?
output = run(["espefuse", "--port", self.port, "summary"])
if "SECURE_BOOT_EN" in output and "= True" in output:
print("✓ 보안 이미 프로비저닝됨, 건너뛰기")
return True
# 실행: eFuse 버닝
return self.burn_security_efuses()
def step3_serial_provision(self):
# 확인: 시리얼이 이미 eFuse에 있나?
existing = self.read_serial_from_efuse()
if existing:
self.serial = existing
print(f"✓ 시리얼 존재: {existing}")
return True
# 실행: 시리얼 생성하고 버닝
mac = self.get_wifi_mac()
self.serial = self.generate_hash64_id(mac)
return self.burn_serial_to_efuse(self.serial)
트레이드오프
| 결정 | 이유 | 대가 |
|---|---|---|
| XTS-AES-256 (2개 키 블록) | ESP32-S3 하드웨어 암호화 표준 | 단일 블록 AES-128보다 복잡 |
| Hash(MAC + secret)로 시리얼 | 결정론적, 위조 불가, 하드웨어에 묶임 | 보안 시크릿 관리 필요 |
| Dev 모드 encryption (CNT=1) | 현장 OTA 펌웨어 업데이트 허용 | 무제한이 아닌 3회 재플래싱 기회 |
| 프로덕션에서 JTAG 활성화 | RMA 기기의 현장 디버깅 | 물리적 접근 시 공격 벡터 |
| 6개 멱등 단계 | 세분화된 복구, 전원 손실 안전 | 기기당 6회 상태 확인 (~2초 오버헤드) |
| S3 펌웨어 스테이징 | 버전 관리, 즉시 롤백 | AWS 의존성, 네트워크 필요 |
| 16KB 인증서 파티션 | X.509 체인 + endpoint + 패딩 수용 | 12KB 낭비 (4KB만 사용) |
결과
성능 (양산 라인에서 측정):
- 총 프로비저닝 시간: 기기당 ~30초
- Security eFuse: ~5초 (버닝 + 검증)
- Factory 펌웨어 플래싱: ~8초 (로컬 캐시)
- 시리얼 프로비저닝: ~2초
- AWS IoT 설정: ~4초 (API 호출)
- 인증서 플래싱: ~3초
- 프로덕션 펌웨어: ~8초 (로컬 캐시)
- 처리량: 스테이션당 시간당 ~120대
신뢰성:
- 벽돌된 기기: 전체 양산에서 0대
- 재시도율: <2% (대부분 AWS 단계의 네트워크 타임아웃)
- 전원 손실 복구: 다음 시도에서 100% 성공
프로비저닝 상태 코드:
| 코드 | 의미 | 동작 |
|---|---|---|
| EFUSE_BURNED | 보안 이미 설정됨 | 1단계 건너뛰기 |
| SERIAL_EXISTS | eFuse에 시리얼 있음 | 2-3단계 건너뛰기 |
| THING_EXISTS | AWS IoT thing 생성됨 | 4단계 건너뛰기 |
| CERTS_VALID | 파티션에 인증서 있음 | 5단계 건너뛰기 |
| PROVISION_COMPLETE | 모든 단계 완료 | 포장 준비 완료 |
핵심 교훈
되돌릴 수 없는 작업(eFuse 버닝)은 방어적 설계가 필요합니다: 쓰기 전 상태 검증, 쓰기 후 검증, 모든 단계를 독립적으로 재시도 가능하게. 6단계 멱등 아키텍처는 전원 손실, 네트워크 실패, 작업자 오류가 절대로 폐기 기기로 이어지지 않도록 보장합니다—보안 eFuse 단계만이 진정으로 복구 불가능하며, 실행 전 3중 확인으로 검증됩니다.