# SSL 증명서 자동화 프로젝트 (2/10): ACME 프로토콜 완전 분해 ## ACME 프로토콜의 본질 ACME(Automatic Certificate Management Environment)는 단순한 API가 아니라 **증명하는 프로토콜**이다. "당신이 이 도메인의 소유자임을 증명하라"는 challenge-response 게임이다. RFC 8555로 표준화된 ACME는 RESTful JSON over HTTPS로 동작하지만, 모든 요청에 JWS(JSON Web Signature)로 서명해야 한다. 이것이 ACME를 까다롭게 만드는 핵심이다. ## ACME 핸드셰이크 전체 흐름 ``` Client ACME Server (CA) DNS/HTTP Server │ │ │ │ 1. GET /directory │ │ │─────────────────────────────>│ │ │ <─ URLs for newAccount, etc. │ │ │ │ │ │ 2. POST /newAccount │ │ │ (JWS signed with pubkey) │ │ │─────────────────────────────>│ │ │ <─ Account created │ │ │ │ │ │ 3. POST /newOrder │ │ │ (identifiers: example.com)│ │ │─────────────────────────────>│ │ │ <─ Order + Authorization URLs│ │ │ │ │ │ 4. POST /authz/xyz │ │ │─────────────────────────────>│ │ │ <─ Challenges (http-01, dns-01) │ │ │ │ │ 5. Fulfill challenge │ │ │─────────────────────────────────────────────────────────────>│ │ (e.g., create DNS TXT record or HTTP file) │ │ │ │ │ 6. POST /challenge/abc │ │ │ ("I'm ready") │ │ │─────────────────────────────>│ │ │ │ 7. Verify challenge │ │ │───────────────────────────────>│ │ │ <─ Challenge validated │ │ <─ Challenge status: valid │ │ │ │ │ │ 8. POST /finalize │ │ │ (CSR in DER format) │ │ │─────────────────────────────>│ │ │ <─ Order status: valid │ │ │ │ │ │ 9. POST /certificate │ │ │─────────────────────────────>│ │ │ <─ PEM certificate chain │ │ ``` ## 실제 HTTPS 요청/응답 분석 ### 1단계: Directory 조회 모든 ACME 세션은 directory 조회로 시작한다. ```http GET /acme/directory HTTP/1.1 Host: acme.globalsign.com ``` 응답: ```json { "newAccount": "https://acme.globalsign.com/acme/new-account", "newNonce": "https://acme.globalsign.com/acme/new-nonce", "newOrder": "https://acme.globalsign.com/acme/new-order", "revokeCert": "https://acme.globalsign.com/acme/revoke-cert", "keyChange": "https://acme.globalsign.com/acme/key-change", "meta": { "termsOfService": "https://www.globalsign.com/repository/", "website": "https://www.globalsign.com", "caaIdentities": ["globalsign.com"] } } ``` ### 2단계: Nonce 획득과 JWS 서명 ACME의 모든 POST 요청은 **JWS(JSON Web Signature)** 로 서명되어야 한다. 리플레이 공격 방지를 위해 각 요청마다 fresh nonce를 사용한다. ```http HEAD /acme/new-nonce HTTP/1.1 Host: acme.globalsign.com ``` 응답 헤더: ```http Replay-Nonce: h8fXZ2K9Lm3Q7wV5tY1nR6pJ4bN8cD2aE0sF9gH7iK Cache-Control: no-store ``` ### 3단계: 계정 생성 (newAccount) JWS 서명 구조: ```json { "protected": "base64url(header)", "payload": "base64url(account_data)", "signature": "base64url(signature)" } ``` **Protected header** (Base64 디코딩 후): ```json { "alg": "RS256", "jwk": { "kty": "RSA", "n": "xGOz...qLWQ", // RSA public key modulus "e": "AQAB" // RSA public exponent (65537) }, "nonce": "h8fXZ2K9Lm3Q7wV5tY1nR6pJ4bN8cD2aE0sF9gH7iK", "url": "https://acme.globalsign.com/acme/new-account" } ``` **Payload** (Base64 디코딩 후): ```json { "termsOfServiceAgreed": true, "contact": ["mailto:admin@example.com"] } ``` **Signature**: `SHA256withRSA(protected + '.' + payload)` 를 private key로 서명 Python 구현 예제: ```python import josepy as jose from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import rsa # 1. 키페어 생성 private_key = rsa.generate_private_key( public_exponent=65537, key_size=2048 ) # 2. JWK 형식으로 변환 jwk = jose.JWKRSA(key=private_key) # 3. Protected header protected = { "alg": "RS256", "jwk": jwk.public_key().to_partial_json(), "nonce": nonce, "url": new_account_url } # 4. Payload payload = { "termsOfServiceAgreed": True, "contact": ["mailto:admin@example.com"] } # 5. JWS 생성 jws = jose.JWS.sign( payload=json.dumps(payload).encode(), key=jwk, alg=jose.RS256, protect=frozenset(protected.keys()), **protected ) # 6. POST 요청 response = requests.post( new_account_url, json=jws.to_compact(), headers={"Content-Type": "application/jose+json"} ) ``` 응답 (201 Created): ```http HTTP/1.1 201 Created Location: https://acme.globalsign.com/acme/account/abc123 Replay-Nonce: n3wN0nc3V4lu3... { "status": "valid", "contact": ["mailto:admin@example.com"], "orders": "https://acme.globalsign.com/acme/account/abc123/orders" } ``` 중요: 이후 모든 요청은 `kid` (Key ID)를 사용한다. ## 4단계: Order 생성과 Challenge 획득 ### Order 생성 ```python # 이제 jwk 대신 kid 사용 protected = { "alg": "RS256", "kid": "https://acme.globalsign.com/acme/account/abc123", # jwk → kid "nonce": new_nonce, "url": new_order_url } payload = { "identifiers": [ {"type": "dns", "value": "www.example.com"}, {"type": "dns", "value": "api.example.com"} ], "notBefore": "2025-12-19T00:00:00Z", "notAfter": "2026-02-06T00:00:00Z" # 47일 유효기간 } ``` 응답: ```json { "status": "pending", "expires": "2025-12-26T00:00:00Z", "identifiers": [ {"type": "dns", "value": "www.example.com"}, {"type": "dns", "value": "api.example.com"} ], "authorizations": [ "https://acme.globalsign.com/acme/authz/123", "https://acme.globalsign.com/acme/authz/456" ], "finalize": "https://acme.globalsign.com/acme/order/789/finalize" } ``` ### Authorization과 Challenge 조회 ```python # POST-as-GET: payload는 빈 문자열 protected = { "alg": "RS256", "kid": account_url, "nonce": nonce, "url": "https://acme.globalsign.com/acme/authz/123" } jws = jose.JWS.sign( payload=b"", # POST-as-GET은 빈 payload key=jwk, # ... ) ``` 응답: ```json { "identifier": {"type": "dns", "value": "www.example.com"}, "status": "pending", "expires": "2025-12-26T00:00:00Z", "challenges": [ { "type": "http-01", "url": "https://acme.globalsign.com/acme/challenge/http123", "token": "a8f3n9d2k5j7m1p6q4r8s0t2u9v7w3x5y1z0" }, { "type": "dns-01", "url": "https://acme.globalsign.com/acme/challenge/dns456", "token": "b9g4o0e3l6k8n2q7r5s9t1u0v8w4x6y2z1a3" }, { "type": "tls-alpn-01", "url": "https://acme.globalsign.com/acme/challenge/tls789", "token": "c0h5p1f4m7l9o3r8s6t0u2v9w5x7y3z2a4b6" } ] } ``` ## Challenge 검증 메커니즘 ### HTTP-01 Challenge **원리**: `http://<도메인>/.well-known/acme-challenge/` 에 특정 값을 배치 ```python # 1. Key Authorization 계산 account_thumbprint = base64url(SHA256(jwk_public_key_json)) key_authorization = f"{token}.{account_thumbprint}" # 예: "a8f3n9d2k5j7m1p6q4r8s0t2u9v7w3x5y1z0.9jg4kl2m5n8p1q3r6s9t0u2v5w8x1y4z7a0" # 2. 웹서버에 파일 배치 with open(f"/var/www/.well-known/acme-challenge/{token}", "w") as f: f.write(key_authorization) # 3. CA가 검증 # GET http://www.example.com/.well-known/acme-challenge/a8f3n9d2... # 응답이 key_authorization과 일치하면 통과 ``` ### DNS-01 Challenge **원리**: `_acme-challenge.<도메인>` TXT 레코드에 특정 값 설정 ```python # 1. Key Authorization 계산 key_authorization = f"{token}.{account_thumbprint}" # 2. DNS Challenge 값 계산 dns_value = base64url(SHA256(key_authorization)) # 예: "xY9mK3nP7qR2sT5uV8wX1zA4bC6dE9fG0hI2jK5lM8" # 3. DNS에 TXT 레코드 추가 # _acme-challenge.www.example.com. IN TXT "xY9mK3nP7qR2sT5uV8wX1zA4bC6dE9fG0hI2jK5lM8" # AWS Route53 예제 import boto3 route53 = boto3.client('route53') route53.change_resource_record_sets( HostedZoneId='Z1234567890ABC', ChangeBatch={ 'Changes': [{ 'Action': 'UPSERT', 'ResourceRecordSet': { 'Name': '_acme-challenge.www.example.com', 'Type': 'TXT', 'TTL': 60, 'ResourceRecords': [{'Value': f'"{dns_value}"'}] } }] } ) ``` ## Challenge 응답과 Polling ```python # Challenge ready 통보 protected = { "alg": "RS256", "kid": account_url, "nonce": nonce, "url": challenge_url } payload = {} # 빈 객체 # POST 후 polling while True: status_response = post_as_get(challenge_url) if status_response["status"] == "valid": break elif status_response["status"] == "invalid": raise ChallengeFailedError(status_response["error"]) time.sleep(2) ``` ## CSR 생성과 Certificate Finalize ```python from cryptography import x509 from cryptography.x509.oid import NameOID # CSR 생성 csr = x509.CertificateSigningRequestBuilder().subject_name( x509.Name([ x509.NameAttribute(NameOID.COMMON_NAME, "www.example.com"), ]) ).add_extension( x509.SubjectAlternativeName([ x509.DNSName("www.example.com"), x509.DNSName("api.example.com"), ]), critical=False, ).sign(private_key, hashes.SHA256()) # DER 포맷으로 인코딩 csr_der = csr.public_bytes(serialization.Encoding.DER) # Finalize 요청 payload = { "csr": base64url(csr_der) } response = post(finalize_url, payload) # Order status가 "processing" → "valid" 로 변경될 때까지 polling ``` ## 증명서 다운로드 ```python # Order status가 "valid"이면 certificate URL 사용 가능 cert_response = post_as_get(order["certificate"]) # PEM 포맷 certificate chain certificate_chain = cert_response.text """ -----BEGIN CERTIFICATE----- MIIFXzCCBEegAwIBAgISA... -----END CERTIFICATE----- -----BEGIN CERTIFICATE----- MIIFjTCCA3WgAwIBAgIQDr... (Intermediate CA) -----END CERTIFICATE----- """ ``` ## 다음 단계 v3에서는 GlobalSign Atlas의 실제 ACME 엔드포인트를 사용한 구현을 다룬다: - GlobalSign Atlas API 키 발급 - Sandbox 환경 설정 - 첫 DV 증명서 자동 발급 - OV 증명서의 추가 검증 프로세스 --- **시리즈 목차** 1. SSL 증명서 자동화 필요성과 프로젝트 개요 2. **[현재글] ACME 프로토콜 이해와 동작 원리** 3. GlobalSign Atlas ACME 연동 실습 4. 도메인 검증 방식 구현 (HTTP-01, DNS-01) 5. ACME 클라이언트 선정과 커스텀 구현 6. 증명서 관리 DB 설계와 API 구현 7. 증명서 배포 자동화 파이프라인 구축 8. 승인 워크플로우와 거버넌스 구현 9. PQC 대응 설계와 미래 확장성 10. 전체 시스템 통합과 프로덕션 배포 가이드