OCPP 보안 프로파일 2 — 충전기와 서버 사이를 TLS와 비밀번호로 잠그기
M8에서 OCPP 1.6J를 확인할 때 보안 프로파일은 0이었다. 충전기와 서버가 평문 WebSocket으로 이야기하고, 서버는 충전기 ID만 보고 접속을 받아 준다. 같은 네트워크에 있는 누구나 메시지를 엿보거나 SC6-0001인 척할 수 있다. 사내 시험에서는 괜찮지만 실제 서비스에서는 쓸 수 없다.
OCPP 1.6 보안 확장에는 프로파일이 셋 있다.
| 프로파일 | 암호화 | 충전기 인증 |
|---|---|---|
| 1 | 없음 | 비밀번호 (HTTP Basic) |
| 2 | TLS | 비밀번호 (HTTP Basic) |
| 3 | TLS | 클라이언트 인증서 (mTLS) |
오늘은 프로파일 2까지 했다.
| 단계 | 결과 |
|---|---|
| 시험용 CA, 서버 인증서 | ✅ ECDSA P-256, 서버 인증서에 192.168.45.139 |
| SteVe HTTPS | ✅ 8180을 HTTPS/WSS로 전환 |
| 보드 설정 | ✅ CA 설치, 프로파일 2 + 비밀번호 |
| 접속 | ✅ BootNotification Accepted |
| 거절 시험 | ✅ 평문, 비밀번호 없음, 틀린 비밀번호 모두 거절 |
1. 양쪽이 무엇을 지원하나
SteVe는 보안 프로파일을 충전기마다 DB에 저장한다. 접속 단계(OcppWebSocketHandshakeHandler)에서 이렇게 검사한다.
프로파일 1, 2 → Basic 인증 헤더 확인, 비밀번호가 틀리면 401
프로파일 2 → 연결이 TLS(https/wss)가 아니면 401 ("평문으로 붙으려 함")
프로파일 3 → 클라이언트 인증서 확인
EVerest(libocpp 1.6) 는 이렇게 동작한다.
SecurityProfile이 2 이상이면 서버 주소 앞에wss://를 붙인다. 주소에 스킴을 안 적어 두면 프로파일만 바꿔도 된다.AuthorizationKey를 비밀번호로 써서SC6-0001:비밀번호를 Basic 인증 헤더에 넣는다.- 서버 인증서는 EvseSecurity의 CSMS 루트 인증서(
/etc/everest/certs/ca/csms/CSMS_ROOT_CA.pem)로 검증한다. 서버 주소와 인증서 이름이 맞는지도 본다(VerifyCsmsCommonName).
마지막 항목 때문에 서버 인증서에 192.168.45.139가 들어가야 한다. 시험 서버를 IP로 부르고 있기 때문이다.
2. 시험용 인증서
빌드 PC에 git 밖 폴더(~/ocpp/tls, 권한 700)를 만들고 OpenSSL로 만들었다.
ca.key / ca.pem 시험용 루트 CA (ECDSA P-256, 10년)
server.key / server.pem SteVe 서버 인증서 (CN, SAN = IP:192.168.45.139, DNS:192.168.45.139)
keystore.p12 SteVe(Jetty)용 PKCS12 (서버 키 + 인증서 + CA)
sc6-0001-authkey.txt SC6-0001 비밀번호
- 키는 ECDSA P-256으로 했다. OCPP 보안 규격이 요구하는 TLS 1.2 암호 조합이 ECDHE-ECDSA 계열이라서다.
- SAN에 IP와 DNS를 둘 다 넣었다. 클라이언트가 IP 주소를 어느 방식으로 비교하든 맞게 하려고.
- 비밀번호와 개인키는 화면에도, 채팅에도, GitHub에도 내지 않는다. 스크립트는 값을 만들고 길이만 출력한다.
server.pem: OK
subject=O = KNV SC6 TEST, CN = 192.168.45.139
X509v3 Subject Alternative Name: IP Address:192.168.45.139, DNS:192.168.45.139
비밀번호 길이: 16~20자
처음에는 40자 hex로 만들었다. SteVe 웹 화면에 넣자 “16자와 20자 사이여야 한다” 며 받지 않았다. libocpp는 최소 8자, 16자 이상을 권장한다. 둘 다 만족하게 영문 대소문자와 숫자 20자로 다시 만들었다. 20자 영숫자는 약 119비트로, 40자 hex(160비트)보다는 짧지만 충분하다.
3. SteVe를 HTTPS로: 8443이 아니었다
SteVe 설정에는 https.port = 8443이 있다. 그래서 처음 계획은 “8180 평문은 그대로 두고 8443을 TLS로 연다”였다. 그런데 설정이 실제로 쓰이는 곳을 찾아 보니 이랬다.
# application.yml
server:
port: ${http.port} # 8180
ssl:
enabled: ${https.enabled}
key-store: ${keystore.path}
https.port는 어디에서도 쓰이지 않는다. HTTPS를 켜면 8180 포트 자체가 HTTPS로 바뀐다. 평문과 TLS를 동시에 열려면 TLS를 풀어 주는 프록시(nginx)를 하나 더 둬야 한다. 시험 서버에 구성 요소를 늘리기보다 8180을 HTTPS로 바꾸는 쪽을 골랐다. 되돌리기도 설정 한 줄이면 된다.
빌드 PC에서 바꾼 것은 두 파일이다(둘 다 .orig 백업).
docker-compose.sc6.yml + /home/knvision/ocpp/tls:/tls:ro (인증서 폴더 읽기 전용)
application-docker.properties https.enabled = true
keystore.path = /tls/keystore.p12
keystore.password = (keystore.pass 에서 읽어 넣음)
Jetty started on port 8180 (ssl, http/1.1) with context path '/steve'
$ openssl s_client -connect 192.168.45.139:8180 -CAfile ca.pem -verify_ip 192.168.45.139
New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384
Verify return code: 0 (ok)
이제 웹 화면도 https://192.168.45.139:8180/steve다. 브라우저는 시험용 CA를 모르니 경고를 한 번 띄운다.
SteVe 웹에서 SC6-0001을 Security Profile 2로 바꾸고 비밀번호를 넣었다. 충전기가 오프라인이라 SteVe는 원격 설정 변경(ChangeConfiguration)을 건너뛰고 DB만 바꿨다.
4. 보드: 비어 있던 루트 인증서, 우선하는 사용자 설정
보드를 보니 두 가지가 있었다.
$ ls -la /etc/everest/certs/ca/csms/
-rw-r--r-- 1 root root 0 Nov 4 16:10 CSMS_ROOT_CA.pem ← 0바이트
$ cat /home/root/m7/ocpp-user-config.json
{"Core":{"HeartbeatInterval":600},"Security":{"SecurityProfile":0}}
- 루트 인증서가 빈 파일이었다. 여기에 시험용 CA를 넣었다.
- libocpp는 기본 설정(
ocpp-config-sc6.json) 위에 사용자 설정 파일을 덮어쓴다. 서버가 원격으로 바꾼 값이 여기에 저장된다. 이 파일에SecurityProfile: 0이 들어 있었으니, 기본 설정만 바꿨다면 프로파일 0으로 떴을 것이다.
그래서 프로파일과 비밀번호를 사용자 설정 파일에 넣었다. 비밀번호는 빌드 PC에서 받은 파일을 읽어 넣고, 넣은 뒤에는 그 파일을 지웠다. 이 설정 파일은 보드에만 있고(권한 600) 저장소에는 없다.
K=$(tr -d '\n' < sc6-0001-authkey.txt)
printf '{"Core":{"HeartbeatInterval":600},"Security":{"SecurityProfile":2,"AuthorizationKey":"%s"}}\n' "$K" > ocpp-user-config.json
unset K; chmod 600 ocpp-user-config.json; rm sc6-0001-authkey.txt
5. 접속
16:55:38.213 [SC6-0001] Connection is established
16:55:38.253 서버→보드 GetConfiguration
16:55:38.284 보드→서버 BootNotification
16:55:38.400 서버→보드 └ BootNotification 응답 {"status":"Accepted", ...}
16:55:38.413 보드→서버 MeterValues (꺼져 있는 동안 쌓인 값)
16:55:38.516 보드→서버 SecurityEventNotification StartupOfTheDevice
한 번에 붙었다. 이 연결이 TLS이고 비밀번호를 통과했다는 근거는 이렇다.
- 서버 8180은 HTTPS만 받는다. 평문으로는 접속 자체가 안 된다.
- SC6-0001은 프로파일 2라서, TLS가 아니거나 비밀번호가 틀리면 SteVe가 접속 단계에서 거절한다.
- 보드는 서버 인증서를 우리 CA와 IP 이름으로 검증한다. 맞지 않으면 접속하지 않는다.
오전에 보드 NTP를 고친 것({ post_url 2026-11-06-sc6-ntp-networkd-online-state })이 여기서도 쓰였다. 인증서는 오늘 16:32(한국 시각)에 만들어졌다. 보드 시계가 틀려 있었다면 “아직 유효하지 않은 인증서”로 거절됐을 수 있다.
6. 거절 시험
정말 잠겼는지 확인하려고 빌드 PC에서 curl로 WebSocket 핸드셰이크만 보냈다. 진짜 비밀번호는 쓰지 않으니 보드 연결에는 영향이 없다.
| 시험 | 응답 | SteVe 로그 |
|---|---|---|
| 평문 ws | 연결 실패 | — |
| wss, 비밀번호 없음 | 401 | Basic 인증 헤더에서 이름·비밀번호를 찾지 못함 |
| wss, 틀린 비밀번호 | 401 | SC6-0001 잘못된 비밀번호 시도 |
| wss, 등록 안 된 ID | 404 | NOPE-0001 인식 안 됨 |
시험하는 동안 보드 연결은 끊기지 않았다.
7. 정리
되돌리는 방법
SteVe: scripts/tls-steve-enable.sh off (https.enabled=false, 재시작)
웹에서 SC6-0001 프로파일 0
보드: cp ocpp-user-config.json.m8 ocpp-user-config.json
오늘 배운 것
- 설정 파일의 키가 실제로 쓰이는지 확인하자.
https.port = 8443은 적혀만 있고 쓰이지 않았다. 계획을 세우기 전에 그 값을 읽는 곳을 찾아봐야 한다. - 덮어쓰는 설정이 어디 있는지 알자. libocpp의 사용자 설정 파일이 기본 설정보다 우선한다. 기본 설정만 고쳤다면 원인을 한참 찾았을 것이다.
- 비밀값은 파일에서 파일로 옮기자. 만들 때도, 보드에 넣을 때도 화면과 채팅에 값이 나오지 않게 했다. 웹 화면에 넣을 때만 사람이 직접 복사했다.
- “붙었다”로 끝내지 말고 “막히는지”도 보자. 접속 성공만으로는 잠겼는지 알 수 없다. 틀린 비밀번호가 401을 받는 것까지 봐야 끝이다.
남은 것
| 항목 | 내용 |
|---|---|
| 실제 서버 인증서 | 시험용 CA 대신 실제 CSMS 운영사의 인증서 체계. 이미지에 CA를 넣는 방법 (M7-6) |
| 비밀번호 보관 | 충전기마다 다른 비밀번호를 생산 때 넣는 방법. ATECC608B(보드의 보안 칩) 활용 여부 (설계자 확인 12번) |
| 프로파일 3 | 클라이언트 인증서(mTLS). 충전기 인증서 발급·갱신 흐름 |
| 이미지 반영 | 루트 인증서와 설정 (M7-6) |
댓글남기기