[Dev] 브라우저 없이 홈택스 공동인증서 로그인 자동화하기

1. 배경: 브라우저 없이 로그인해야 하는 이유

홈택스 같은 공공기관 사이트에서 데이터를 수집하려면 먼저 로그인을 통과해야 한다.

가장 손쉬운 방법은 Selenium이나 Playwright로 실제 브라우저를 띄워 사람이 하듯 클릭하는 것이다. 하지만 브라우저 방식은 무겁고 화면 구조가 조금만 바뀌어도 깨지며 서버 여러 대에서 대량으로 돌리기 어렵다.

그래서 브라우저를 거치지 않고 requests로 로그인 HTTP 요청을 직접 재현하는 방식을 검토했다.

문제는 홈택스 로그인이 공동인증서(구 공인인증서)를 쓴다는 점이다. 쿠키만 담아 요청을 보내는 방식으로는 로그인이 되지 않는다. 브라우저에서 처리하던 인증서 기반 암호화와 전자서명 절차를 코드로 그대로 재현해야 한다.

정리하면 두 가지를 코드로 옮겨야 한다.

  • 인증서 파일에서 개인키를 꺼내 전자서명을 만드는 암호 처리
  • 로그인 완료 후 받은 세션 쿠키로 이어지는 스크래핑

이 글은 금융결제원 개인 공동인증서를 기준으로 진행한 PoC를 예시로 설명한다. 본문에 나오는 인증서 문자열, 일련번호, 서명값, 세션 쿠키, 서버 응답값은 모두 형태만 보여주기 위한 예시 값이며 실제 발급 인증서의 값이 아니다.


2. 공동인증서 안에는 무엇이 들어 있나

공동인증서는 공개키와 개인키 한 쌍으로 이루어진 전자 신분증이다. 종이 계약서에 도장을 찍듯, 컴퓨터에서는 개인키로 전자서명을 만들어 신원을 증명한다.

두 키의 역할은 분명히 갈린다.

  • 공개키: 남에게 공개해도 되는 키. 서명이 진짜인지 검증하는 데 쓴다.
  • 개인키: 절대 노출되면 안 되는 키. 실제 서명을 만드는 데 쓴다.

도장에 빗대면 공개키는 인감증명서, 개인키는 실제 도장, 인증서 비밀번호는 도장을 넣어 둔 금고 비밀번호에 해당한다.

도장 공동인증서
인감증명서 공개키가 담긴 인증서
실제 도장 개인키
금고 비밀번호 인증서 비밀번호

이 구조를 PKI(공개키 기반구조)라고 한다. 나만 가진 개인키로 서명하고 상대는 공개키로 그 서명이 진짜인지 검증한다. 인증기관은 그 공개키가 실제로 해당 사용자의 것임을 보증한다. 한국의 공동인증서도 이 방식을 따르며 인증서 자체는 국제적으로 널리 쓰이는 X.509 형식을 기반으로 한다.


2.1 어떤 파일로 저장되나

예전부터 써 온 NPKI 방식의 공동인증서는 PC나 USB에 보통 이렇게 저장된다.

NPKI/
  └─ (발급기관)/
       └─ USER/
            └─ cn=사용자,ou=은행명,o=발급기관,c=kr/
                 ├─ signCert.der   # 인증서 (공개키)
                 └─ signPri.key    # 암호화된 개인키

핵심 파일은 두 개다.

signCert.der는 인증서다. 특정 공개키가 누구의 것인지 증명하는 전자 증명서로, 공개해도 되는 공개키와 발급기관 정보가 들어 있다. 서명을 받은 상대는 이 파일의 공개키로 서명이 진짜인지 확인한다.

signPri.key에는 개인키가 들어 있다. 개인키는 실제 도장에 해당해 유출되면 안 되므로 파일에 그대로 저장하지 않고 인증서 비밀번호로 암호화해서 담는다.

파일 역할 용도
signCert.der 도장 증명서 서명이 진짜인지 확인
signPri.key 비밀번호로 잠긴 도장 실제 전자서명 생성

결국 홈택스에 공동인증서로 로그인하려면 signPri.key 안에 잠긴 개인키를 꺼내 쓸 수 있어야 한다.


2.2 비밀번호만으로 개인키가 바로 나오지 않는 이유

signPri.key는 비밀번호를 넣으면 곧바로 열리는 단순한 구조가 아니다. 실제로는 비밀번호를 재료로 암호를 풀 새로운 키를 만들어낸 다음 그 키로 개인키를 복호화한다. 이 과정을 키 파생(Key Derivation)이라고 한다.

게다가 발급기관과 인증서 종류에 따라 암호화 방식이 다를 수 있다. 대표적인 발급기관은 다음과 같다.

발급기관 홈페이지
금융결제원 yessign.or.kr
한국정보인증 signgate.com
코스콤 signkorea.com
한국전자인증 crosscert.com
KTNET tradesign.net

이 글은 금융결제원 개인 공동인증서(은행·신용카드·보험용)를 기준으로 살펴본다. 필요한 것은 세 가지다.

  • 사용자가 보유한 signCert.der
  • 사용자가 보유한 signPri.key
  • 이 파일을 해석하고 암호를 풀 프로그램

파이썬으로 구현한다면 ASN.1 구조를 읽는 asn1crypto, 암호 처리용 pycryptodome, 홈택스와 통신할 requests를 쓴다.


3. 개인키를 여는 과정

이제 signPri.key라는 금고를 연다. 입력한 비밀번호가 곧 개인키는 아니다. 비밀번호를 재료로 금고를 여는 열쇠를 한 번 더 만들어낸 뒤 그 열쇠로 개인키를 복호화한다. 아래에서 이 과정을 단계별로 본다.


3.1 SEED-CBC

금융결제원 개인 공동인증서의 개인키는 SEED라는 알고리즘으로 암호화된다. SEED는 KISA가 개발한 블록 암호로, 긴 데이터를 한 번에 처리하지 않고 일정한 크기로 잘라 암호화한다. 한 블록은 128비트(16바이트)다.

블록 암호는 잘린 블록들을 어떤 순서로 이어 암호화할지 정하는 운영모드와 함께 쓴다. 대표적으로 ECB와 CBC가 있다.

운영모드 방식 특징
ECB 각 블록을 독립적으로 암호화 같은 평문 블록이 같은 암호문이 되어 반복 패턴이 드러난다
CBC 직전 암호문을 다음 블록에 섞어 암호화 같은 내용이 반복돼도 앞 결과에 따라 암호문이 달라진다

CBC는 앞 블록의 암호문을 다음 블록에 섞어 이어진다.

블록1              ─ 암호화 → 암호문1
블록2 + 암호문1     ─ 암호화 → 암호문2
블록3 + 암호문2     ─ 암호화 → 암호문3

첫 블록은 앞선 암호문이 없으므로 대신 IV(초기화 벡터)를 섞는다. 개인키는 이 SEED-CBC로 잠겨 있고 여기에 쓸 암호화 키를 인증서 비밀번호에서 파생한다. 참고로 SEED는 한국 국가표준일 뿐 국제 표준은 아니라 pycryptodome 같은 범용 라이브러리에는 들어 있지 않고 KISA가 공개한 알고리즘 소스를 참조해 구현해야 한다.


3.2 signPri.key 파일의 구조

signPri.key에는 암호문만 들어 있는 것이 아니라 어떤 방법으로 암호화했는지 알려주는 정보도 함께 담긴다. 이 구조를 ASN.1 형식으로 표현한다. ASN.1은 데이터를 정해진 규칙대로 포장하는 방법이다.

signPri.key의 구조를 정리하면 이렇다.

EncryptedPrivateKeyInfo
├─ encryptionAlgorithm        # 어떻게 암호화했는지
│   ├─ algorithm : OID        # 암호 방식 식별자
│   └─ parameters
│        ├─ salt              # 키 파생용 재료
│        └─ iterationCount    # 반복 횟수
└─ encryptedData              # 암호화된 개인키

세 값의 의미는 다음과 같다.

OID는 암호화 방식을 가리키는 번호표다. 예를 들어 1.2.410.200004.1.15가 들어 있으면 프로그램은 이 인증서가 SEED-CBC와 SHA-1을 쓰는 KISA 방식임을 알아낸다. 다른 인증서는 PKCS#5의 PBES2(1.2.840.113549.1.5.13)로 감싸 PBKDF2로 키를 파생하고 SEED-CBC로 암호화한 형태일 수도 있다. 그래서 인증서를 열 때는 한 가지 방법을 무조건 적용하지 않고 먼저 OID를 읽어 방식을 판별한 뒤 그에 맞게 복호화한다.

salt는 비밀번호에 섞는 추가 재료다. salt가 없으면 같은 비밀번호를 쓰는 두 사람의 복호화 키가 같아질 수 있다. 인증서마다 다른 salt를 섞으면 같은 비밀번호라도 서로 다른 키가 만들어진다.

iterationCount는 비밀번호에서 키를 만들 때의 반복 횟수다(예: 2048회). 정상 로그인은 한 번만 계산하면 되지만 비밀번호를 무작위로 대입하는 공격자는 후보마다 이 반복 계산을 전부 치러야 한다. 대입 공격을 느리게 만드는 장치다.


3.3 비밀번호로 개인키를 여는 과정

앞의 값들을 연결하면 복호화가 완성된다.

사용자 비밀번호 + salt + iterationCount
      ↓  (정해진 규칙으로 계산 = 키 파생)
SEED 복호화 키
      ↓
encryptedData 복호화
      ↓
실제 개인키

비밀번호가 틀리면 올바른 복호화 키가 나오지 않으므로 개인키도 정상적으로 풀리지 않는다.


3.4 복호화 후에 나오는 것

복호화가 성공하면 PrivateKeyInfo 구조가 나오고 그 안에 전자서명에 쓸 RSA 개인키와 전자서명용 난수가 함께 들어 있다.

PrivateKeyInfo
├─ privateKeyAlgorithm : rsa
├─ privateKey (RSAPrivateKey)
│   └─ n, e, d, p, q ...              # RSA 키를 이루는 숫자들
└─ attributes
    └─ type : 1.2.410.200004.10.1.1.3  # 전자서명용 난수 (KISA 지정)
        └─ values : BIT STRING         # randomNum

RSA 개인키를 이루는 n, e, d, p, q는 전자 도장을 찍는 데 필요한 비밀 정보다. 공개키는 남에게 줘도 되지만 이 개인키는 외부에 노출되면 안 된다. 홈택스 로그인에서는 서버가 보낸 난수를 이 개인키로 서명한다.

randomNum은 KISA 공동인증서 규격이 정해 둔 추가 값이다. PoC에서 확인한 흐름에서는 이 값을 꺼내 홈택스 요청의 randomEnc 파라미터를 만든다.

정리하면 복호화 후 두 가지를 확보한다.

  • RSA 개인키: 전자서명 생성용
  • randomNum: 홈택스 인증 요청에 필요한 추가 데이터(randomEnc)

3.5 전체 과정 한눈에 보기

여기까지를 한 줄기로 이으면 이렇다.

① signPri.key 읽기
② OID 확인 → 암호 방식 판별
③ 인증서 비밀번호 입력
④ 비밀번호 + salt + 반복 횟수 → 복호화 키 생성
⑤ SEED-CBC로 개인키 복호화
⑥ RSA 개인키 추출
⑦ randomNum 추출

여기서 가장 중요한 점은 인증서 비밀번호를 홈택스로 보내지 않는다는 것이다. 비밀번호는 내 PC에서 개인키를 여는 데만 쓰고 열린 개인키로 만든 서명값만 서버로 보낸다. 제대로 구현하면 비밀번호와 개인키는 사용자 PC 안에서만 처리된다.


4. 홈택스 로그인 요청 분해

인증서에서 개인키와 난수를 꺼냈으니, 이제 홈택스가 요구하는 요청을 만들 차례다.

홈택스처럼 공동인증서로 로그인하는 사이트의 핵심은 전자서명이다. 서버가 매 로그인마다 난수(challenge)를 내려주고, 클라이언트는 그 난수를 개인키로 서명해 되돌려 준다. 서버는 그 서명을 인증서의 공개키로 검증해 인증서를 실제로 소유한 사람인지 확인한다.

로그인은 세 단계로 이어진다.

① 메인 접속        → 세션 쿠키 발급 (WMONID, TXPPsessionID)
② challenge 요청   → 서명할 난수(pkcEncSsn) 수신
③ 로그인 요청      → 인증서 + 전자서명 전송, 세션 확정

세 요청은 같은 세션으로 묶여야 한다. 응답으로 받은 쿠키를 다음 요청의 쿠키로 그대로 실어 보내야 서버가 동일 세션으로 인식한다. requests.Session()을 쓰면 쿠키가 자동으로 이어진다.

4.1 세션 쿠키와 challenge 받기

메인 페이지에 접속해 세션 쿠키를 받은 뒤, 그 쿠키가 실린 상태에서 challenge 발급 요청을 보낸다. 요청 헤더에는 브라우저와 동일한 User-Agent와 Accept 계열 값을 채운다. 이 challenge 요청은 JSON 본문으로 보내고 응답 본문에서 서명 대상 난수인 pkcEncSsn을 꺼낸다.

import requests

session = requests.Session()

# ① 메인 페이지 접속 → 세션 쿠키 발급
session.get("https://www.hometax.go.kr/", headers=headers)

# ② 쿠키가 세팅된 상태에서 challenge(pkcEncSsn) 요청
post_url = (
    "https://www.hometax.go.kr/wqAction.do"
    "?actionId=ATXPPZXA001R01&screenId=UTXPPABA01"
)
payload = {"pageNo": "", "pageRef": "", "pageOrder": "",
           "pageCl": "", "pageExcelYn": "", "map": {}}

resp = session.post(post_url, json=payload, headers=post_headers)
pkc_enc_ssn = resp.json().get("pkcEncSsn")

이 시점에 가지고 있는 값은 다음과 같다. 쿠키와 난수 모두 세션마다 새로 발급되므로 아래는 형태만 보여주는 예시다.

세션 쿠키 : {
  'TXPPsessionID': 'Qb7Kd2Rf9Gh3Vj5Nl6Ws7Zp8Cr4St0Xv1Bx2Ym3Aq4Dd5Ef6Hj7Kk8Lp9Tn0Rc.wsp07_servlet_TXPP04',
  'WMONID': 'Kp7Rt2_qWzB'
}
pkcEncSsn : Kx9AbCd2Ef3Gh4Ij5Kl6Mn7Op+Y=

4.2 전자서명 파라미터 조립

로그인 요청에 실을 값은 다섯 개다.

파라미터 설명 만드는 방법
cert PEM 형식 인증서 signCert.der를 Base64로 인코딩해 PEM으로 감싼다
logSgnt 전자서명 결과 아래 네 값을 $로 이어 붙인다
randomEnc 전자서명용 난수 signPri.key 복호화 때 얻은 randomNum
pkcLgnClCd 로그인 구분 코드 '04' 고정
pkcLoginYnImpv 로그인 방식 플래그 'Y' 고정

cert는 DER 그대로가 아니라 PEM 문자열로 보내야 서버가 인식한다.

-----BEGIN CERTIFICATE-----
MIIFrz...(Base64 인코딩된 인증서 본문, 예시)...4/ylIw==
-----END CERTIFICATE-----

핵심은 logSgnt다. 다음 네 값을 순서대로 $로 이어 붙인 문자열이다.

logSgnt = pkcEncSsn + "$" + 인증서_일련번호 + "$" + yyyyMMddHHmmss + "$" + 서명값
순서 구성요소 출처
1 pkcEncSsn ②에서 서버가 내려준 challenge 원본값
2 인증서 일련번호 signCert.der를 ASN.1 파싱해 Serial 추출 (예: 1A2B3C4D)
3 서명 시각 yyyyMMddHHmmss 형식의 현재 시각 문자열
4 서명값 pkcEncSsn을 개인키로 서명한 값

4번 서명값은 SHA256withRSA로 만든다. 앞서 복호화한 RSA 개인키로 pkcEncSsn에 서명하고 Base64로 인코딩한다. 이 서명이 인증서 소유를 증명하는 핵심 값이다.

4.3 로그인 요청

조립한 값들을 로그인 API에 POST로 보낸다. 이 요청은 JSON이 아니라 폼 인코딩(application/x-www-form-urlencoded)으로 보내며 ②에서 받은 세션 쿠키를 그대로 실어야 한다.

url = "https://hometax.go.kr/pubcLogin.do?domain=hometax.go.kr&mainSys=Y"

session = requests.Session()
session.cookies.update(session_cookies)   # ②에서 받은 쿠키 유지

payload = {
    "logSgnt": log_sgnt,            # pkcEncSsn$일련번호$시각$서명값
    "cert": cert,                   # PEM 인증서
    "randomEnc": random_enc,        # signPri.key의 randomNum
    "pkcLoginYnImpv": "Y",
    "pkcLgnClCd": "04",
    "scrnId": "UTXPPABA01",
    "userScrnRslnXcCnt": "1920",
    "userScrnRslnYcCnt": "1080",
}

response = session.post(url, headers=headers, data=payload)
print(response.text)

로그인이 성공하면 콜백 형태의 응답이 돌아온다. code가 S, lgnRsltCd가 01이면 성공이다.

nts_loginSystemCallback('TXPP', { 'code' : 'S', 'errCode' : null,
  'errMsg' : '', 'lgnRsltCd' : '01', 'pswdErrNbcnt' : null,
  'tin' : null, 'secCardId' : null });

이후로는 이 세션의 쿠키가 로그인 상태를 유지한다. 같은 session 객체로 홈택스 내부 조회 API를 호출해 필요한 데이터를 수집하면 된다.


5. 정리

브라우저 없이 홈택스에 로그인하는 자동화는 결국 HTTP 요청과 암호 처리를 함께 재현하는 일이다.

  • HTTP 재현: 메인 접속으로 세션 쿠키를 받고 challenge를 요청하고 로그인 요청을 보내는 세 단계를 같은 세션으로 잇는다.
  • 암호 재현: 브라우저가 자바스크립트로 하던 인증서 처리를 코드로 옮긴다. signPri.key를 SEED-CBC로 복호화해 RSA 개인키와 난수를 꺼내고 서버가 준 pkcEncSsn을 SHA256withRSA로 서명한다.

가장 큰 난관은 개인키 복호화다. SEED가 국제 표준이 아니라 범용 라이브러리에 없고 발급기관과 인증서 종류마다 암호화 알고리즘이 달라 인증서별로 복호화 로직을 맞춰야 한다. PoC는 금융결제원 개인 공동인증서를 기준으로 복호화와 전자서명, 로그인까지 성공을 확인했다.

다른 발급기관 인증서로 넓히려면 signPri.key의 encryptionAlgorithm OID를 읽어 알고리즘을 판별하고 기관별 복호화 모듈을 붙이는 작업이 이어져야 한다.

Categories:

Updated:

Leave a comment