# SSL 증명서 자동화 프로젝트 (3/10): GlobalSign Atlas ACME 실전 연동 ## GlobalSign Atlas 플랫폼 이해 GlobalSign의 Atlas는 차세대 PKI 플랫폼으로, 레거시 웹 콘솔 기반 운영에서 API-First 아키텍처로의 전환을 의미한다. ### Atlas vs Legacy 플랫폼 비교 ``` Legacy Platform (2020년 이전) ├─ 웹 UI에서 수동 CSR 제출 ├─ 이메일 기반 승인 ├─ 증명서 다운로드 후 수동 배포 └─ API: 제한적 (주문 조회 정도만) Atlas Platform (2021년 이후) ├─ RESTful API 중심 설계 ├─ ACME 프로토콜 네이티브 지원 ├─ Webhook 이벤트 알림 ├─ 대시보드는 API 위에 구축된 UI └─ Multi-tenancy (부서별 격리) ``` **중요**: 기존 계약이 레거시 플랫폼이라면 Atlas로 마이그레이션이 필요하다. GlobalSign 영업팀에 문의하여 다음을 확인: - Atlas 지원 여부 - 마이그레이션 비용 - ACME 지원 제품 (DV/OV/EV) ## 사전 준비: Atlas 계정 설정 ### 1. API 키 발급 Atlas Portal (`https://atlas.globalsign.com`)에 로그인 후: 1. **Settings** → **API Credentials** → **Create New API Key** 2. 권한 설정: ``` ✓ acme.read ✓ acme.write ✓ certificates.read ✓ certificates.write ✓ orders.read ✓ orders.write ``` 3. API Key와 API Secret 안전하게 저장 (한 번만 표시됨) ```bash # 환경 변수로 관리 (절대 git에 커밋하지 말 것) export GLOBALSIGN_API_KEY="gsa_live_xxxxxxxxxxxxxxxxxx" export GLOBALSIGN_API_SECRET="yyyyyyyyyyyyyyyyyyyyyyyyyyy" ``` ### 2. ACME EAB (External Account Binding) 설정 Atlas ACME는 **EAB (External Account Binding)** 를 사용하여 ACME 계정을 Atlas 계정과 연결한다. 이는 공개 ACME 서버(Let's Encrypt)와의 주요 차이점이다. Atlas Portal에서: 1. **ACME** → **External Account Bindings** → **Create EAB** 2. 용도 입력 (예: "Production ACME Client") 3. **EAB Key ID**와 **EAB HMAC Key** 생성됨 ```bash export EAB_KID="eab_xxxxxxxxxxxxxxxxxxxxxxxxxx" export EAB_HMAC_KEY="zzzzzzzzzzzzzzzzzzzzzzzzzz" ``` ### 3. Sandbox 환경 설정 프로덕션 배포 전 Sandbox에서 테스트하는 것이 필수다. ```python # config.py ACME_ENDPOINTS = { "sandbox": { "directory": "https://acme-sandbox.globalsign.com/v2/directory", "eab_kid": os.getenv("SANDBOX_EAB_KID"), "eab_hmac": os.getenv("SANDBOX_EAB_HMAC") }, "production": { "directory": "https://acme.globalsign.com/v2/directory", "eab_kid": os.getenv("PROD_EAB_KID"), "eab_hmac": os.getenv("PROD_EAB_HMAC") } } ``` ## 첫 ACME 클라이언트 구현 ### 프로젝트 구조 ``` acme-client/ ├── requirements.txt ├── config.py ├── acme_client.py ├── challenges/ │ ├── http01.py │ └── dns01.py └── storage/ ├── account_key.pem └── certificates/ ``` ### requirements.txt ```txt acme==2.7.4 cryptography==41.0.7 josepy==1.14.0 requests==2.31.0 boto3==1.34.20 # AWS Route53용 dnspython==2.4.2 ``` ### EAB를 사용한 계정 생성 GlobalSign Atlas의 핵심: EAB를 사용한 계정 바인딩 ```python # acme_client.py import json import base64 import hashlib import hmac from acme import client, messages from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.hazmat.primitives import serialization import josepy as jose class GlobalSignACMEClient: def __init__(self, directory_url, eab_kid, eab_hmac_key): self.directory_url = directory_url self.eab_kid = eab_kid self.eab_hmac_key = eab_hmac_key self.account_key = None self.client = None def generate_account_key(self): """ACME 계정용 RSA 키 생성""" private_key = rsa.generate_private_key( public_exponent=65537, key_size=2048 ) # PEM 형식으로 저장 pem = private_key.private_bytes( encoding=serialization.Encoding.PEM, format=serialization.PrivateFormat.PKCS8, encryption_algorithm=serialization.NoEncryption() ) with open("storage/account_key.pem", "wb") as f: f.write(pem) self.account_key = jose.JWKRSA(key=private_key) return self.account_key def load_account_key(self): """저장된 계정 키 로드""" with open("storage/account_key.pem", "rb") as f: private_key = serialization.load_pem_private_key( f.read(), password=None ) self.account_key = jose.JWKRSA(key=private_key) return self.account_key def create_eab_credentials(self): """EAB (External Account Binding) 생성""" # EAB는 HMAC-SHA256로 서명된 JWS account_public_key = self.account_key.public_key().to_partial_json() # Protected header protected = { "alg": "HS256", "kid": self.eab_kid, "url": self.directory.newAccount.uri } # Payload: 계정의 공개키 (JWK) payload = json.dumps(account_public_key).encode() # HMAC 서명 protected_b64 = base64.urlsafe_b64encode( json.dumps(protected).encode() ).rstrip(b'=') payload_b64 = base64.urlsafe_b64encode(payload).rstrip(b'=') signing_input = protected_b64 + b'.' + payload_b64 signature = hmac.new( self.eab_hmac_key.encode(), signing_input, hashlib.sha256 ).digest() signature_b64 = base64.urlsafe_b64encode(signature).rstrip(b'=') return { "protected": protected_b64.decode(), "payload": payload_b64.decode(), "signature": signature_b64.decode() } def register_account(self, email): """ACME 계정 등록 (EAB 사용)""" # ACME 클라이언트 초기화 net = client.ClientNetwork(self.account_key) self.directory = messages.Directory.from_json( net.get(self.directory_url).json() ) self.client = client.ClientV2(self.directory, net=net) # EAB credentials 생성 eab = self.create_eab_credentials() # 계정 등록 (EAB 포함) regr = self.client.new_account( messages.NewRegistration.from_data( email=email, terms_of_service_agreed=True, external_account_binding=eab ) ) print(f"✓ Account created: {regr.uri}") return regr ``` ### 전체 증명서 발급 플로우 ```python def issue_certificate(self, domains, challenge_type="dns-01"): """증명서 발급 (DNS-01 challenge 사용)""" # 1. Order 생성 order = self.client.new_order( [messages.Identifier(typ=messages.IDENTIFIER_FQDN, value=d) for d in domains] ) print(f"✓ Order created: {order.uri}") # 2. 각 도메인에 대한 Challenge 수행 for authz in order.authorizations: # Authorization 정보 가져오기 authz_response = self.client.poll_and_request_challenges(authz) # DNS-01 challenge 선택 challenge = None for chall in authz_response.body.challenges: if isinstance(chall.chall, challenges.DNS01): challenge = chall break if not challenge: raise ValueError("DNS-01 challenge not available") # DNS TXT 레코드 값 계산 domain = authz_response.body.identifier.value validation = challenge.validation(self.account_key) print(f"\n⚠ DNS TXT 레코드를 추가하세요:") print(f" Name: _acme-challenge.{domain}") print(f" Type: TXT") print(f" Value: {validation}") # 실제 구현에서는 Route53 API 자동 호출 self._create_dns_record(domain, validation) # DNS 전파 대기 print("⏳ DNS 전파 대기 중... (60초)") time.sleep(60) # Challenge 응답 self.client.answer_challenge(challenge, challenge.response(self.account_key)) # Validation 완료 대기 print("⏳ Challenge validation 대기 중...") try: finalized_authz = self.client.poll_authorization(authz_response, deadline=300) print(f"✓ {domain} validated") except errors.ValidationError as e: print(f"✗ Validation failed: {e}") raise # 3. CSR 생성 private_key = rsa.generate_private_key( public_exponent=65537, key_size=2048 ) csr = self._generate_csr(domains, private_key) # 4. Finalize (CSR 제출) finalized_order = self.client.finalize_order(order, csr) print("✓ Order finalized") # 5. 증명서 다운로드 certificate = finalized_order.fullchain_pem # 6. 저장 cert_path = f"storage/certificates/{domains[0]}.pem" key_path = f"storage/certificates/{domains[0]}.key" with open(cert_path, "w") as f: f.write(certificate) with open(key_path, "wb") as f: f.write(private_key.private_bytes( encoding=serialization.Encoding.PEM, format=serialization.PrivateFormat.PKCS8, encryption_algorithm=serialization.NoEncryption() )) print(f"✓ Certificate saved: {cert_path}") print(f"✓ Private key saved: {key_path}") return certificate, private_key def _create_dns_record(self, domain, validation): """Route53에 DNS TXT 레코드 생성""" import boto3 route53 = boto3.client('route53') # Hosted Zone ID 조회 (실제로는 설정에서 가져옴) hosted_zone_id = self._get_hosted_zone_id(domain) route53.change_resource_record_sets( HostedZoneId=hosted_zone_id, ChangeBatch={ 'Changes': [{ 'Action': 'UPSERT', 'ResourceRecordSet': { 'Name': f'_acme-challenge.{domain}', 'Type': 'TXT', 'TTL': 60, 'ResourceRecords': [{'Value': f'"{validation}"'}] } }] } ) ``` ### 실행 예제 ```python # main.py if __name__ == "__main__": from config import ACME_ENDPOINTS # Sandbox 환경 사용 config = ACME_ENDPOINTS["sandbox"] client = GlobalSignACMEClient( directory_url=config["directory"], eab_kid=config["eab_kid"], eab_hmac_key=config["eab_hmac"] ) # 계정 키 생성 (최초 1회) # client.generate_account_key() # 또는 기존 키 로드 client.load_account_key() # 계정 등록 (최초 1회) # client.register_account("admin@example.com") # 증명서 발급 domains = ["www.example.com", "api.example.com"] certificate, private_key = client.issue_certificate(domains) print("\n=== Certificate Info ===") # 증명서 정보 출력 from cryptography import x509 cert = x509.load_pem_x509_certificate(certificate.encode()) print(f"Subject: {cert.subject}") print(f"Issuer: {cert.issuer}") print(f"Valid from: {cert.not_valid_before}") print(f"Valid until: {cert.not_valid_after}") ``` ## OV/EV 증명서의 추가 검증 DV 증명서는 ACME만으로 완전 자동화되지만, OV/EV는 추가 조직 검증이 필요하다. ### OV 증명서 플로우 ``` 1. ACME로 도메인 검증 (자동) 2. 조직 검증 요청 생성 (수동 또는 반자동) ├─ 사업자 등록증 제출 ├─ 전화 인증 └─ 담당자 확인 3. GlobalSign 심사팀 승인 (1-3 영업일) 4. 승인 후 증명서 발급 (자동) ``` **API 통합 포인트**: ```python # OV 증명서의 경우 Organization Validation 체크 order = client.new_order(domains) # Order status가 "pending" → "processing"이 되면 # GlobalSign Portal에서 조직 검증 상태 확인 while order.status == "processing": # 조직 검증 완료 대기 # Webhook 또는 polling으로 상태 체크 time.sleep(3600) # 1시간마다 체크 order = client.poll_order(order) # status가 "ready"가 되면 finalize 가능 ``` ## 트러블슈팅 가이드 ### 문제 1: EAB 인증 실패 ``` Error: urn:ietf:params:acme:error:externalAccountRequired ``` **원인**: EAB credentials가 없거나 잘못됨 **해결**: ```python # EAB HMAC Key가 base64로 저장되어 있는지 확인 eab_hmac = base64.b64decode(os.getenv("EAB_HMAC_KEY")) ``` ### 문제 2: DNS Challenge 실패 ``` Error: DNS query for _acme-challenge.example.com failed ``` **원인**: DNS 전파 미완료 또는 잘못된 값 **디버깅**: ```bash # DNS 레코드 확인 dig _acme-challenge.www.example.com TXT +short # 예상 출력: "xY9mK3nP7qR2sT5uV8wX1zA4bC6dE9fG0hI2jK5lM8" ``` ### 문제 3: Rate Limit 초과 GlobalSign Atlas의 rate limits (Sandbox 기준): - 계정 생성: 10/hour - Order 생성: 50/hour - Challenge 검증: 100/hour **대응**: Exponential backoff + retry 로직 구현 ## 다음 단계 v4에서는 도메인 검증 방식을 심화 학습한다: - HTTP-01: 웹서버 자동 설정 (Nginx/Apache) - DNS-01: Route53/CloudFlare 완전 자동화 - TLS-ALPN-01: 로드밸런서 통합 - 멀티 도메인 병렬 검증 최적화 --- **시리즈 목차** 1. SSL 증명서 자동화 필요성과 프로젝트 개요 2. ACME 프로토콜 이해와 동작 원리 3. **[현재글] GlobalSign Atlas ACME 연동 실습** 4. 도메인 검증 방식 구현 (HTTP-01, DNS-01) 5. ACME 클라이언트 선정과 커스텀 구현 6. 증명서 관리 DB 설계와 API 구현 7. 증명서 배포 자동화 파이프라인 구축 8. 승인 워크플로우와 거버넌스 구현 9. PQC 대응 설계와 미래 확장성 10. 전체 시스템 통합과 프로덕션 배포 가이드