8 min read

파이썬으로 모빌리언스 휴대폰 인증 콜백 데이터 복호화 하기

회사에서 개발 중인 서비스에서 휴대폰 본인인증 기능이 필요하게 되었다. 이에 KG모빌리언스 PG사에 휴대폰본인인증 서비스를 신청했다.

휴대폰 본인인증은 대충 다음과 같은 플로우로 이루어진다.

  1. 웹페이지에 hidden form 데이터로 가맹점 정보 및 콜백 URL을 담고, 이 페이지를 클라이언트에게 보여준다. 이 페이지는 document.onload에서 PG사에서 제공하는 Javascript 함수를 호출하여, form 데이터를 PG사의 Endpoint에 submit 시킨다.
  2. PG사의 Endpoint에서는 휴대폰 본인인증이 진행되며, 이 과정이 완료되면 PG사는 인증 정보를 hidden form 데이터로 담아 클라이언트에게 보여준다. 그리고 이 데이터를 콜백 URL로 submit 시키도록 Javascript 함수를 호출한다.
  3. 개발자는 콜백 URL의 핸들러에서 form 데이터를 받아서 이케이케 쓰면 된다.

흔히 보는 OAuth 연동과정과 유사하다. 단지 300 Redirect를 쓰는지, 아니면 전달할 데이터를 form에 담아 POST 찌르도록 하는 방법을 쓰는지 다를 뿐이다. CSRF니 어쩌니 해서 복잡한 문제가 있지만, 이 포스팅의 관심범위 밖이니까 일단 넘어가자.

연동 자체는 큰 문제 없이 이루어졌지만, 문제는 데이터 자체에 에 있었다. 다른 필드들은 별 문제가 없지만 개인정보와 관련된 부분(이름, 생일, 휴대폰번호, 고유식별번호 등)은 의무적으로 암호화 되어 전달된다. TLS 쓰면 되지 않을까 생각이 들지만, 여하튼 의무적으로 암호화해야 한댄다.

암호화 된 데이터가 온다면 복호화 하면 된다. 그리고 KG모빌리언스에서는 복호화 모듈을 포함한 콜백 URL 핸들러 샘플 코드를 제공한다. 그런데 이게 ASP, JSP, PHP로만 제공된다. 파이썬? 그런거 없다. 야이씨... 하릴없이 소스를 뜯어야 하는데 그나마 내가 만질 줄 아는 코드는 PHP니까 그 코드를 까 봤다. 그리고 당당히 등장하는 그 이름, SEED.

SEED 알고리즘이 나오게 된 계기에 대해서는 넘어가기로 하고, 여하튼 KG모빌리언스는 개인정보 암호화에 KISA에서 만든 SEED-128를 사용한댄다. 추측컨대 다른 PG사도 똑같이 SEED를 사용할 것이다. PHP 샘플 코드에 포함된 SEED 구현체를 까 보니, C 라이브러리를 사용하는 것도 아니고 진짜로 바이트 연산을 일일이 PHP 문법으로 코딩해 둔 생짜 구현체이다. 속도 문제는 둘째치고, 이걸 파이썬으로 포팅해야 하나? 내가 직접?

다행히도(?) SEED 알고리즘은 RFC4269로 공개되어 있고, 최신 버전의 OpenSSL에도 그 구현체가 포함되어 있다. 그리고 파이썬에는 OpenSSL 기반으로 데이터 암호화 기능을 제공하는 Cryptography 패키지가 존재한다. 문서를 뒤지고 뒤져보니 Cryptography에서도 SEED 알고리즘을 지원한다. 아 일단 다행이다.

데이터를 복호화 하려면 두 가지 값을 알아야 한다. 하나는 SEED-128 Preshared key이고, 다른 하나는 PG사에서 보내준 ciphertext 이다. 전자는 PG사 관리자 페이지에 미리 등록해 준 16글자 영숫자 문자열이고, ciphertext는 바이너리 데이터를 hexstring으로 보내주므로 이를 다시 바이너리 형태로 바꿔줘야 한다. 그럼 이제 복호화를 해 보자.

from cryptography.hazmat.primitives.ciphers import Cipher
from cryptography.hazmat.primitives.ciphers.algorithms import SEED
from cryptography.hazmat.primitives.ciphers.modes import ECB

SEED_PRESHARED_KEY = b"SUPERSECRET23456"
cipher = Cipher(SEED(SEED_PRESHARED_KEY), ECB())


def pg_decode(ciphertext: str) -> bytes:
    ciphertext = bytes.fromhex(ciphertext)
    decryptor = cipher.decryptor()
    plaintext = decryptor.update(ciphertext)
    plaintext += decryptor.finalize()
    return plaintext


def pg_callback(form_data):
    # some mysterious code block
    auth_name = pg_decode(form_data.get("Name"))
    auth_phone = pg_decode(form_data.get("Number"))
    # some mysterious code block

그렇게 Endpoint를 대충 꾸리고, 연동 테스트를 해 보았다. 한 방에 잘 되었다면 이 글을 쓰지도 않았을 것이다. 당연히 안 된다. 아이씨 뭐야. 샘플 PHP 코드를 디깅 시작해 보자. 참고로 오픈 태그를 <? 사용하는 10년은 더 묵은 예제 코드다.

Preshared key

SEED-128 Preshared key는 PG사에 미리 등록한 16글자 문자열이다. 그런데 이걸 바로 사용하지 않는다. 앞 바이트부터 순서대로 [1, 2, ..., 16]을 XOR 처리하여 사용한다. 웨?...

여하튼 그렇게 되어 있으니 바꿔야지. 코드를 고쳤다.

from cryptography.hazmat.primitives.ciphers import Cipher
from cryptography.hazmat.primitives.ciphers.algorithms import SEED
from cryptography.hazmat.primitives.ciphers.modes import ECB

MCASH_PRESHARED_KEY = b"SUPERSECRET23456"
SEED_PRESHARED_KEY = b"".join(
    map(lambda c, m: c ^ m,
        MCASH_PRESHARED_KEY,
        range(1, len(MCASH_PRESHARED_KEY)+1)
    )
)
cipher = Cipher(SEED(SEED_PRESHARED_KEY), ECB())


def pg_decode(ciphertext: str) -> bytes:
    ciphertext = bytes.fromhex(ciphertext)
    decryptor = cipher.decryptor()
    plaintext = decryptor.update(ciphertext)
    plaintext += decryptor.finalize()
    return plaintext


def pg_callback(form_data):
    # some mysterious code block
    auth_name = pg_decode(form_data.get("Name"))
    auth_phone = pg_decode(form_data.get("Number"))
    # some mysterious code block

위 코드에서 SEED_PRESHARED_KEY 값과 PHP SEED 구현체에 입력되는 Key 값이 같음을 확인했다. 이제 잘 되겠지? 당연하지만 안된다. 아니 씨 또 왜!

Endian swap

위 코드에서 ciphertext와 PHP SEED 구현체로 입력되는 ciphertext도 같은 값임을 확인했다. 그럼 이제 구현체 내부에 무언가가 있다는 이야기이다. OpenSSL 코드 중 SEED 구현 부분과 PHP SEED 구현체를 side-by-side로 띄워두고 찬찬히 비교해 보기 시작했다.

그러다 좀 이상한 부분을 발견했는데, PHP SEED 구현체는 입력된 Preshared key와 바이너리 데이터를 4바이트 단위로 잘라서는 int32 형태로 패킹하고, 리틀 엔디안인 경우 int32값에 엔디안 스왑을 진행한 뒤 복호화 라운드를 시작하는 것이다. 엥?

KISA 참조문서를 확인해 보면, SEED 알고리즘은 x86 구조를 기본으로 하여 리틀 엔디안을 고려하여 설계되어 있다고 되어 있긴 하다. 근데 이건 어디까지나 각종 상수값들이 저장되는 방식, 내지는 라운드 내에서 int32 단위로 bitwise 연산을 할 때 적용되는거지... 입력 데이터는 바이트 스트림이라 엔디안 고려할 필요가 없는데? 아니 게다가 기본값이 리틀 엔디안인데, 리틀 엔디안일 때 바이트 스왑을 한다? 대체 뭐야 혼란스럽기 끝이 없다.

결국 생각을 관두기로 하고, 구현체에 되어 있는 그대로 옮기기로 했다. Preshared key와 입력 바이너리 데이터를 4바이트 단위로 잘라서 flip하고 다시 묶어서 cryptography 패키지에 넘겨주도록 코드를 수정하였다.

from cryptography.hazmat.primitives.ciphers import Cipher
from cryptography.hazmat.primitives.ciphers.algorithms import SEED
from cryptography.hazmat.primitives.ciphers.modes import ECB


def endian_flip(_in: bytes) -> bytes:
    return b"".join(_in[i:i + 4][::-1] for i in range(0, len(_in), 4))


MCASH_PRESHARED_KEY = b"SUPERSECRET23456"
SEED_PRESHARED_KEY = b"".join(
    map(lambda c, m: c ^ m,
        MCASH_PRESHARED_KEY,
        range(1, len(MCASH_PRESHARED_KEY)+1)
    )
)
cipher = Cipher(SEED(endian_flip(SEED_PRESHARED_KEY)), ECB())


def pg_decode(ciphertext: str) -> bytes:
    ciphertext = endian_flip(bytes.fromhex(ciphertext))
    decryptor = cipher.decryptor()
    plaintext = decryptor.update(ciphertext)
    plaintext += decryptor.finalize()
    return endian_flip(plaintext)


def pg_callback(form_data):
    # some mysterious code block
    auth_name = pg_decode(form_data.get("Name"))
    auth_phone = pg_decode(form_data.get("Number"))
    # some mysterious code block

그 결과 복호화 성공. 물론 이렇게 복호화 된 문자열은 EUC-KR (내지는 MS CP949) 인코딩이므로, 이를 별도로 UTF-8로 변환해 줘야 한다. 다행히 파이썬에서는 bytes.decode("euc-kr") 한 줄이면 해결 가능하다.

웨 이렇게 표준과 어긋나게 만든 구현체를 아직까지 쓰는지 도통 알 수 없는 일이며, 더 좋은 알고리즘이 나왔는데도 그냥 SEED-128에 안주하는지도 알 수 없는 일이다. 2021년에 PHP 5.x 시절의 문법으로 만들어진 예제 코드가 제공되는 것이라던지, 예제 코드가 ASP JSP PHP만 제공되는 것이라던지 여전히 EUC-KR을 사용하는 부분이라던지, 여하튼 한국 IT시스템의 지하7층 20년간 열어본 적 없는 관리패널을 뜯어 본 기분이었다.

그나마 이런 삽질을 12시간 넘어가기 전에 끝마친 나 자신에게 위로의 말을 한 마디 해 주면서 기록을 마친다.

** 본 문서에서 제공되는 코드는 참조 목적으로만 사용해야 하며, 저자는 본 문서에서 제공되는 코드가 문제 없이 작동함을 보증하지 않습니다. 본인 책임 하에 사용하십시오.