HMAC 인증 방식은 해시 기반 메시지 인증 코드(HMAC)를 사용하여 메시지 무결성과 인증을 보장하는 보안 기술입니다.
목차
- 소개
- HMAC 인증의 기본 원리
- HMAC 인증 구현하기
- 보안 고려사항과 모범 사례
- HMAC vs 다른 인증 방식 비교
- 결론
- 참고 자료
소개
API 보안은 현대 웹 서비스에서 가장 중요한 요소 중 하나입니다. 다양한 인증 방식 중에서 HMAC(Hash-based Message Authentication Code) 인증 방식은 메시지의 무결성과 신원 확인을 동시에 보장하는 강력한 메커니즘을 제공합니다. 이 글에서는 HMAC 인증 방식의 원리부터 실제 구현까지 상세히 알아보겠습니다.
이 글에서 배울 내용:
- HMAC 알고리즘의 작동 원리와 암호학적 기초
- 다양한 프로그래밍 언어에서 HMAC 인증 구현 방법
- HMAC 인증을 적용할 때의 보안 모범 사례
HMAC 인증의 기본 원리
HMAC이란 무엇인가?
HMAC(Hash-based Message Authentication Code)는 메시지 인증을 위한 암호화 기법으로, 비밀 키와 해시 함수를 조합하여 메시지의 무결성과 신원을 검증합니다. HMAC은 단순한 해시 함수와 달리 비밀 키를 사용하기 때문에, 중간자 공격(man-in-the-middle attack)이나 재전송 공격(replay attack)에 강한 보안성을 제공합니다.
HMAC 알고리즘은 다음과 같은 수식으로 표현됩니다:
HMAC(K,m) = H((K' ⊕ opad) || H((K' ⊕ ipad) || m))
여기서:
- K는 비밀 키
- m은 메시지
- H는 해시 함수(SHA-256, SHA-512 등)
- K'는 해시 함수의 블록 크기에 맞게 조정된 키
- opad와 ipad는 내부 패딩과 외부 패딩 상수
- ⊕는 XOR 연산, ||는 연결 연산자
java
// HMAC 계산의 기본 원리 (의사 코드)
public static byte[] hmac(byte[] key, byte[] message, MessageDigest hashFunction) throws Exception {
int blockSize = 64; // SHA-256의 블록 크기
// 키가 해시 함수의 블록 크기보다 크면 해싱하여 줄임
if (key.length > blockSize) {
key = hashFunction.digest(key);
}
// 키가 짧으면 패딩으로 블록 크기까지 늘림
if (key.length < blockSize) {
byte[] newKey = new byte[blockSize];
System.arraycopy(key, 0, newKey, 0, key.length);
key = newKey;
}
// 내부/외부 패딩 생성
byte[] ipad = new byte[blockSize];
byte[] opad = new byte[blockSize];
for (int i = 0; i < blockSize; i++) {
ipad[i] = 0x36;
opad[i] = 0x5c;
}
// XOR 연산 및 해싱
byte[] keyXorIpad = new byte[blockSize];
byte[] keyXorOpad = new byte[blockSize];
for (int i = 0; i < blockSize; i++) {
keyXorIpad[i] = (byte) (key[i] ^ ipad[i]);
keyXorOpad[i] = (byte) (key[i] ^ opad[i]);
}
// 내부 해싱
hashFunction.reset();
hashFunction.update(keyXorIpad);
hashFunction.update(message);
byte[] innerHash = hashFunction.digest();
// 외부 해싱
hashFunction.reset();
hashFunction.update(keyXorOpad);
hashFunction.update(innerHash);
byte[] outerHash = hashFunction.digest();
return outerHash;
}
HMAC 인증 프로세스
HMAC 기반 API 인증의 일반적인 흐름은 다음과 같습니다:
1. 클라이언트와 서버가 사전에 비밀 키를 공유합니다.
2. 클라이언트가 요청을 보낼 때:
- 요청 데이터(URL, 메서드, 헤더, 본문 등)를 기반으로 메시지 문자열을 생성합니다.
- 공유된 비밀 키를 사용하여 메시지의 HMAC을 계산합니다.
- HMAC 서명을 요청 헤더에 포함시켜 서버로 전송합니다.
3. 서버가 요청을 받으면:
- 동일한 방식으로 요청 데이터로부터 메시지 문자열을 생성합니다.
- 동일한 비밀 키로 HMAC을 계산합니다.
- 계산된 HMAC과 클라이언트가 보낸 서명을 비교합니다.
- 일치하면 요청을 처리하고, 불일치하면 거부합니다.
💡 팁: 타임스탬프를 메시지에 포함시켜 재전송 공격을 방지할 수 있습니다. 서버는 일정 시간(보통 5-15분) 이상 지난 요청을 거부하는 방식으로 구현합니다.
HMAC 인증 구현하기
java
// 서버 측 HMAC 검증 필터 (Spring Framework)
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Enumeration;
import java.util.stream.Collectors;
import org.apache.commons.codec.binary.Hex;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
public class HmacAuthenticationFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
try {
// 요청에서 인증 헤더 가져오기
String apiKey = request.getHeader("X-API-Key");
String hmacSignature = request.getHeader("X-HMAC-Signature");
String timestamp = request.getHeader("X-Timestamp");
if (apiKey == null || hmacSignature == null || timestamp == null) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.getWriter().write("{\"error\":\"Missing authentication headers\"}");
return;
}
// API 키로 클라이언트 비밀 키 조회 (실제로는 DB에서 조회)
String secretKey = getClientSecretKey(apiKey);
if (secretKey == null) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.getWriter().write("{\"error\":\"Invalid API Key\"}");
return;
}
// 현재 시간과 타임스탬프의 차이 확인 (재전송 공격 방지)
long currentTime = Instant.now().getEpochSecond();
long requestTime = Long.parseLong(timestamp);
if (Math.abs(currentTime - requestTime) > 300) { // 5분 허용
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.getWriter().write("{\"error\":\"Timestamp expired\"}");
return;
}
// 서명할 메시지 생성
String method = request.getMethod();
String path = request.getRequestURI();
// 요청 본문 읽기
String body = request.getReader().lines().collect(Collectors.joining());
String message = method + path + timestamp + body;
// HMAC 계산
Mac sha256Hmac = Mac.getInstance("HmacSHA256");
SecretKeySpec secretKeySpec = new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
sha256Hmac.init(secretKeySpec);
byte[] hmacBytes = sha256Hmac.doFinal(message.getBytes(StandardCharsets.UTF_8));
String calculatedSignature = Hex.encodeHexString(hmacBytes);
// 서명 비교
if (calculatedSignature.equals(hmacSignature)) {
// 인증 성공, 요청 처리 계속
filterChain.doFilter(request, response);
} else {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.getWriter().write("{\"error\":\"Invalid signature\"}");
}
} catch (Exception e) {
logger.error("HMAC 인증 중 오류 발생", e);
response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
response.getWriter().write("{\"error\":\"Authentication error\"}");
}
}
private String getClientSecretKey(String apiKey) {
// 실제 구현에서는 DB나 캐시에서 API 키에 해당하는 비밀 키를 조회
// 여기서는 예시로 하드코딩
if ("test-api-key".equals(apiKey)) {
return "test-secret-key";
}
return null;
}
}
클라이언트 측 구현 (JavaScript)
클라이언트에서는 요청을 보내기 전에 HMAC 서명을 계산하여 헤더에 포함시켜야 합니다.
java
// 클라이언트 측 HMAC 서명 생성 (Java)
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import org.apache.commons.codec.binary.Hex;
public class HmacClient {
private final String apiKey;
private final String secretKey;
private final HttpClient httpClient;
public HmacClient(String apiKey, String secretKey) {
this.apiKey = apiKey;
this.secretKey = secretKey;
this.httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
}
public HttpResponse<String> sendRequest(String method, String url, String body) throws Exception {
// 타임스탬프 생성 (Unix 시간)
String timestamp = String.valueOf(System.currentTimeMillis() / 1000);
// 서명할 메시지 생성
String message = method + url + timestamp + (body != null ? body : "");
// HMAC 서명 계산
Mac sha256Hmac = Mac.getInstance("HmacSHA256");
SecretKeySpec secretKeySpec = new SecretKeySpec(
secretKey.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
sha256Hmac.init(secretKeySpec);
byte[] hmacBytes = sha256Hmac.doFinal(message.getBytes(StandardCharsets.UTF_8));
String signature = Hex.encodeHexString(hmacBytes);
// HTTP 요청 생성
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Content-Type", "application/json")
.header("X-API-Key", apiKey)
.header("X-HMAC-Signature", signature)
.header("X-Timestamp", timestamp);
HttpRequest request;
switch (method.toUpperCase()) {
case "GET":
request = requestBuilder.GET().build();
break;
case "POST":
request = requestBuilder.POST(HttpRequest.BodyPublishers.ofString(body != null ? body : ""))
.build();
break;
case "PUT":
request = requestBuilder.PUT(HttpRequest.BodyPublishers.ofString(body != null ? body : ""))
.build();
break;
case "DELETE":
request = requestBuilder.DELETE().build();
break;
default:
throw new IllegalArgumentException("Unsupported HTTP method: " + method);
}
// 요청 전송 및 응답 반환
return httpClient.send(request, HttpResponse.BodyHandlers.ofString());
}
// 사용 예시
public static void main(String[] args) {
try {
HmacClient client = new HmacClient("test-api-key", "test-secret-key");
HttpResponse<String> response = client.sendRequest(
"GET",
"
https://api.example.com/data
",
null
);
System.out.println("Status code: " + response.statusCode());
System.out.println("Response body: " + response.body());
} catch (Exception e) {
e.printStackTrace();
}
}
}
보안 고려사항과 모범 사례
키 관리 전략
HMAC 인증의 보안성은 비밀 키 관리에 크게 의존합니다. 효과적인 키 관리를 위한 모범 사례는 다음과 같습니다:
주요 장점:
- 비밀 키는 충분히 길고 무작위적이어야 합니다 (최소 256비트 길이 권장).
- 키는 안전한 저장소(HSM, KMS 등)에 보관하고, 평문으로 코드나 설정 파일에 저장하지 마세요.
- 주기적인 키 교체 메커니즘을 구현하여 키 노출 시 피해를 최소화하세요.
타임스탬프와 논스(Nonce) 활용
재전송 공격을 방지하기 위한 추가적인 보안 조치로 타임스탬프와 논스를 활용할 수 있습니다.
java
// 타임스탬프와 논스를 포함한 HMAC 서명 생성
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import org.apache.commons.codec.binary.Hex;
public class HmacWithNonce {
public static HmacSignature createHmacWithNonce(String method, String url, String body, String secretKey)
throws Exception {
// 타임스탬프 생성
String timestamp = String.valueOf(System.currentTimeMillis() / 1000);
// 무작위 논스 생성 (16바이트)
byte[] nonceBytes = new byte[16];
SecureRandom secureRandom = new SecureRandom();
secureRandom.nextBytes(nonceBytes);
String nonce = Hex.encodeHexString(nonceBytes);
// 메시지 생성
String message = method + url + timestamp + nonce + (body != null ? body : "");
// HMAC 계산
Mac sha256Hmac = Mac.getInstance("HmacSHA256");
SecretKeySpec secretKeySpec = new SecretKeySpec(
secretKey.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
sha256Hmac.init(secretKeySpec);
byte[] hmacBytes = sha256Hmac.doFinal(message.getBytes(StandardCharsets.UTF_8));
String signature = Hex.encodeHexString(hmacBytes);
return new HmacSignature(signature, timestamp, nonce);
}
// 서명 결과를 담는 클래스
public static class HmacSignature {
private final String signature;
private final String timestamp;
private final String nonce;
public HmacSignature(String signature, String timestamp, String nonce) {
this.signature = signature;
this.timestamp = timestamp;
this.nonce = nonce;
}
public String getSignature() {
return signature;
}
public String getTimestamp() {
return timestamp;
}
public String getNonce() {
return nonce;
}
}
}
HMAC vs 다른 인증 방식 비교
인증 방식별 특징
다양한 API 인증 방식의 장단점을 비교해보겠습니다.
인증 방식 | 주요 기능 | 보안 수준 | 구현 복잡성 |
API 키 | 단일 토큰 기반 인증 | 낮음 | 매우 간단 |
HMAC | 메시지 서명 기반 인증 | 높음 | 보통 |
OAuth 2.0 | 위임 액세스 프로토콜 | 높음 | 복잡함 |
JWT | Self-contained 토큰 | 중간-높음 | 보통 |
mTLS | 상호 TLS 인증 | 매우 높음 | 복잡함 |
HMAC의 장단점
HMAC 인증 방식의 주요 장단점은 다음과 같습니다:
장점:
- 메시지 무결성과 신원 확인을 동시에 보장
- 비밀 키가 네트워크로 전송되지 않음
- 타임스탬프와 함께 사용 시 재전송 공격에 강함
- 서버 측에서 세션 상태를 유지할 필요가 없음
단점:
- 클라이언트와 서버 간 시간 동기화 필요
- 구현이 OAuth나 API 키보다 복잡함
- 비밀 키 관리에 주의가 필요함
HMAC 인증의 고급 적용 사례
캐노니컬 요청 형식
대규모 API 시스템에서는 요청의 모든 부분(쿼리 파라미터, 헤더 등)을 일관된 형식으로 정규화하여 서명하는 것이 중요합니다. AWS 서명 버전 4와 유사한 캐노니컬 형식의 예시입니다.
java
// 캐노니컬 요청 형식 생성 (AWS Signature V4 스타일)
import java.net.URI;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.stream.Collectors;
public class CanonicalRequest {
public static String createCanonicalRequest(String method, String url,
Map<String, String> headers,
Map<String, String> queryParams,
String body) throws Exception {
// URL 경로 추출
URI uri = new URI(url);
String path = uri.getPath();
// 쿼리 파라미터 정렬 및 인코딩
TreeMap<String, String> sortedQueryParams = new TreeMap<>(queryParams);
String canonicalQueryString = sortedQueryParams.entrySet().stream()
.map(entry -> {
try {
return URLEncoder.encode(entry.getKey(), "UTF-8") + "=" +
URLEncoder.encode(entry.getValue(), "UTF-8");
} catch (Exception e) {
throw new RuntimeException("URL 인코딩 실패", e);
}
})
.collect(Collectors.joining("&"));
// 헤더 정렬 및 소문자로 변환
TreeMap<String, String> sortedHeaders = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
sortedHeaders.putAll(headers);
StringBuilder canonicalHeaders = new StringBuilder();
for (Map.Entry<String, String> header : sortedHeaders.entrySet()) {
canonicalHeaders.append(header.getKey().toLowerCase())
.append(":")
.append(header.getValue().trim())
.append("\n");
}
// 서명에 포함된 헤더 이름들
String signedHeaders = sortedHeaders.keySet().stream()
.map(String::toLowerCase)
.collect(Collectors.joining(";"));
// 요청 본문 해시 생성
MessageDigest sha256 = MessageDigest.getInstance("SHA-256");
byte[] bodyHash = sha256.digest((body != null ? body : "").getBytes(StandardCharsets.UTF_8));
String hexBodyHash = bytesToHex(bodyHash);
// 캐노니컬 요청 형식 조합
return method + "\n" +
path + "\n" +
canonicalQueryString + "\n" +
canonicalHeaders + "\n" +
signedHeaders + "\n" +
hexBodyHash;
}
private static String bytesToHex(byte[] bytes) {
StringBuilder result = new StringBuilder();
for (byte b : bytes) {
result.append(String.format("%02x", b));
}
return result.toString();
}
// 사용 예시
public static void main(String[] args) {
try {
// 요청 정보
String method = "POST";
String url = "
https://api.example.com/resource/path?queryParam1=value1
";
// 헤더 생성
Map<String, String> headers = new HashMap<>();
headers.put("Host", "api.example.com");
headers.put("Content-Type", "application/json");
headers.put("X-Amz-Date", "20250518T120000Z");
// 쿼리 파라미터
Map<String, String> queryParams = new HashMap<>();
queryParams.put("queryParam1", "value1");
// 요청 본문
String body = "{\"key\":\"value\"}";
String canonicalRequest = createCanonicalRequest(method, url, headers, queryParams, body);
System.out.println("Canonical Request:");
System.out.println(canonicalRequest);
} catch (Exception e) {
e.printStackTrace();
}
}
}
다층 보안을 위한 HMAC 활용
HMAC은 다른 보안 메커니즘과 함께 사용하여 다층 방어(defense in depth)를 구현할 수 있습니다.
적용 예시
java
// JWT와 HMAC을 결합한 보안 강화 예시
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.util.Date;
import java.util.Map;
import org.apache.commons.codec.binary.Hex;
public class SecureTokenGenerator {
public static class SecureToken {
private final String token;
private final String signature;
public SecureToken(String token, String signature) {
this.token = token;
this.signature = signature;
}
public String getToken() {
return token;
}
public String getSignature() {
return signature;
}
}
public static SecureToken createSecureToken(Map<String, Object> payload, String jwtSecret, String hmacSecret) {
// JWT 토큰 생성
Key key = Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8));
String token = Jwts.builder()
.setClaims(payload)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + 3600000)) // 1시간 유효
.signWith(key, SignatureAlgorithm.HS256)
.compact();
// JWT 토큰에 대한 HMAC 서명 추가
try {
Mac sha256Hmac = Mac.getInstance("HmacSHA256");
SecretKeySpec secretKeySpec = new SecretKeySpec(
hmacSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
sha256Hmac.init(secretKeySpec);
byte[] hmacBytes = sha256Hmac.doFinal(token.getBytes(StandardCharsets.UTF_8));
String signature = Hex.encodeHexString(hmacBytes);
return new SecureToken(token, signature);
} catch (Exception e) {
throw new RuntimeException("HMAC 서명 생성 실패", e);
}
}
// 서버 측 검증
public static Map<String, Object> verifySecureToken(String token, String signature,
String jwtSecret, String hmacSecret) {
// HMAC 서명 검증
try {
Mac sha256Hmac = Mac.getInstance("HmacSHA256");
SecretKeySpec secretKeySpec = new SecretKeySpec(
hmacSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
sha256Hmac.init(secretKeySpec);
byte[] hmacBytes = sha256Hmac.doFinal(token.getBytes(StandardCharsets.UTF_8));
String calculatedSignature = Hex.encodeHexString(hmacBytes);
if (!calculatedSignature.equals(signature)) {
throw new RuntimeException("유효하지 않은 HMAC 서명");
}
// JWT 토큰 검증
Key key = Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8));
Map<String, Object> claims = Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
return claims;
} catch (Exception e) {
throw new RuntimeException("토큰 검증 실패: " + e.getMessage(), e);
}
}
// 사용 예시
public static void main(String[] args) {
try {
// 페이로드 생성
Map<String, Object> payload = new HashMap<>();
payload.put("userId", 123);
// 보안 토큰 생성
SecureToken secureToken = createSecureToken(
payload, "jwt-secret-key", "hmac-secret-key");
System.out.println("Token: " + secureToken.getToken());
System.out.println("HMAC Signature: " + secureToken.getSignature());
// 토큰 검증
Map<String, Object> claims = verifySecureToken(
secureToken.getToken(), secureToken.getSignature(),
"jwt-secret-key", "hmac-secret-key");
System.out.println("Verified claims: " + claims);
} catch (Exception e) {
e.printStackTrace();
}
}
}
HMAC vs 다른 인증 방식 비교
실제 서비스에서의 선택 가이드
어떤 인증 방식을 선택해야 할지는 서비스의 특성과 보안 요구사항에 따라 달라집니다.
도구 | 주요 기능 | 링크 |
HMAC 인증 | 높은 보안성, 메시지 무결성 보장 | https://tools.ietf.org/html/rfc2104 |
OAuth 2.0 | 사용자 인증 위임, 서드파티 접근 제어 | https://oauth.net/2/ |
JWT | Self-contained 토큰, 무상태 인증 | https://jwt.io/ |
보안 강화를 위한 추가 조치
HMAC 인증과 함께 사용할 수 있는 추가적인 보안 조치들입니다:
- TLS(HTTPS) 사용 강제
- 속도 제한(Rate Limiting) 구현
- IP 주소 기반 필터링 추가
- 사용자별 API 키 권한 세분화
결론
HMAC 인증 방식은 API 보안을 위한 강력하고 효과적인 솔루션입니다. 비밀 키를 네트워크로 전송하지 않으면서도 메시지 무결성과 신원 확인을 동시에 보장할 수 있어, 다양한 보안 요구사항을 충족시킵니다. 올바르게 구현하고 모범 사례를 따른다면, HMAC은 중간자 공격이나 재전송 공격과 같은 일반적인 API 보안 위협으로부터 시스템을 보호하는 데 큰 도움이 됩니다.
핵심 요약:
- HMAC은 해시 함수와 비밀 키를 조합한 메시지 인증 코드로, API 인증에 효과적입니다.
- 올바른 구현을 위해서는 타임스탬프, 논스, 캐노니컬 요청 형식 등의 추가 요소가 중요합니다.
- 비밀 키 관리는 HMAC 보안의 핵심이므로, 안전한 저장과 주기적인 교체가 필수적입니다.
참고 자료
- HMAC: RFC 2104
- AWS Signature 버전 4 서명 프로세스
- OWASP API 보안 Top 10
'IT' 카테고리의 다른 글
[IT] SHA-1, SHA-2, SHA-256 해시 알고리즘의 차이점 (2) | 2023.12.02 |
---|