4 분 소요

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

오늘 배운 것

  1. 설정 파일의 키가 실제로 쓰이는지 확인하자. https.port = 8443은 적혀만 있고 쓰이지 않았다. 계획을 세우기 전에 그 값을 읽는 곳을 찾아봐야 한다.
  2. 덮어쓰는 설정이 어디 있는지 알자. libocpp의 사용자 설정 파일이 기본 설정보다 우선한다. 기본 설정만 고쳤다면 원인을 한참 찾았을 것이다.
  3. 비밀값은 파일에서 파일로 옮기자. 만들 때도, 보드에 넣을 때도 화면과 채팅에 값이 나오지 않게 했다. 웹 화면에 넣을 때만 사람이 직접 복사했다.
  4. “붙었다”로 끝내지 말고 “막히는지”도 보자. 접속 성공만으로는 잠겼는지 알 수 없다. 틀린 비밀번호가 401을 받는 것까지 봐야 끝이다.

남은 것

항목 내용
실제 서버 인증서 시험용 CA 대신 실제 CSMS 운영사의 인증서 체계. 이미지에 CA를 넣는 방법 (M7-6)
비밀번호 보관 충전기마다 다른 비밀번호를 생산 때 넣는 방법. ATECC608B(보드의 보안 칩) 활용 여부 (설계자 확인 12번)
프로파일 3 클라이언트 인증서(mTLS). 충전기 인증서 발급·갱신 흐름
이미지 반영 루트 인증서와 설정 (M7-6)

댓글남기기