HMAC(Hash-based Message Authentication Code)

6 분 소요


RECOMMEND POSTS BEFORE THIS

1. MAC(Message Authentication Code)

메시지 인증 코드(MAC, Message Authentication Code)는 메시지 인증과 위변조를 검출하기 위해 사용합니다. 메시지 인증 코드를 통해 수신(receiving) 서비스는 다음과 같은 내용들을 확인할 수 있습니다.

  • 비밀 키를 통해 암호화하므로 허가된 메시지인지 데이터 인증이 가능합니다.
  • 메시지를 사용해 인증 코드를 만들기 때문에 공격자에 의해 메시지가 위변조 되었는지 무결성 검증이 가능합니다.

다음과 같은 과정을 통해 인증 과정이 이뤄집니다.

  1. 송신자는 메시지와 비밀 키를 함께 암호화 알고리즘에 통과시켜 MAC을 획득합니다.
  2. 송신자는 메시지와 MAC을 함께 송신합니다.
  3. 수신자는 전달 받은 메시지와 미리 공유하고 있던 비밀 키를 함께 암호화 알고리즘에 통과시켜 MAC을 획득합니다.
    • 이때 사용한 비밀 키는 대칭 키(symmetric key)입니다.
  4. 수신자는 전달 받은 MAC과 새롭게 생성한 MAC을 비교합니다.
    • 같은 MAC 값을 갖는 경우 정상적으로 처리합니다.
    • 다른 MAC 값을 갖는 경우 메시지가 위변조 되었다고 판단합니다.

https://en.wikipedia.org/wiki/Message_authentication_code

2. HMAC(Hash-based Message Authentication Code)

HMAC(Hash-based Message Authentication Code)은 이름처럼 해시(hash)의 특징을 사용해 보안을 강화한 메시지 인증 코드입니다. 다음과 같은 해시의 특징들을 사용합니다.

  • 단방향 암호화를 수행하므로 복호화가 불가능합니다.
  • 원본 데이터의 크기에 상관 없이 고정 길이의 데이터로 변환합니다.
  • 원본 데이터의 작은 변화에도 전혀 다른 해시 코드가 생성됩니다.
    • 작은 위변조에 전혀 다른 값이 출력되므로 데이터 무결성을 검증할 수 있습니다.

다음과 같은 방식을 통해 HMAC이 생성됩니다.

  • H - 암호화를 위한 해시 함수
    • MD5, SHA-1, SHA-224, SHA-256, SHA-512 등이 사용됩니다.
  • m - 인증이 필요한 메시지
  • K - 비밀 키
    • 사전에 발급 받으며 대칭 키를 사용합니다.
    • 유출 우려가 있을 경우 재발급해서 사용해야합니다.
  • K′ - 비밀 키로부터 파생된 블럭 사이즈의 비밀 키
    • 비밀 키를 그대로 사용하기도 합니다.
  • ∥ - 문자열 연결 연산
  • ⊕ - 비트 XOR 연산
  • opad - 블럭 사이즈를 가진 외부 패딩
    • 0x5c 값으로 채웁니다.
  • ipad - 블럭 사이즈를 가진 내부 패딩
    • 0x36 값으로 채웁니다.

https://en.wikipedia.org/wiki/HMAC

3. Practice

HMAC은 데이터 무결성을 확인하기 위한 도구로 사용됩니다. 일반적으로 HTTPS 같은 보안 채널을 통해 메시지를 전송하고 혹시 모를 메시지의 위변조를 HMAC을 통해 검증합니다. 다음과 같은 시나리오를 만족하는 HMAC 검증 필터를 만들어 위변조 된 메시지를 검증하는 기능을 구현해보았습니다.

  1. 송신자는 비밀 키, 메시지, 타임스탬프(timestamp)를 사용해 HMAC을 생성합니다.
  2. 송신자는 API 요청 시 메시지, 타임스탬프 그리고 생성한 HMAC를 전달합니다.
  3. 수신자는 메시지, 타임스탬프, 미리 발급 받은 비밀 키를 통해 HMAC을 생성합니다.
  4. 수신자는 전달받은 HMAC과 생성한 HMAC을 비교합니다.

3.1. TodoController Class

  • 4개의 엔드포인트(endpoint)가 존재합니다.
  • GET, DELETE 요청은 HMAC 검증을 수행하지 않습니다.
  • POST, PUT 요청은 HMAC 검증을 수행합니다.
    • POST 요청은 application/json 타입의 메시지 요청을 받습니다.
    • PUT 요청은 application/x-www-form-urlencoded 타입의 메시지 요청을 받습니다.
package action.in.blog;

import lombok.Builder;
import org.springframework.web.bind.annotation.*;

import java.util.Arrays;
import java.util.List;

record Todo(
        long id,
        String content
) {
    @Builder
    public Todo {
    }
}

@RestController
class TodoController {

    @GetMapping("/todos")
    public List<Todo> getPosts() {
        return Arrays.asList(
                new Todo(1000, "Hello"),
                new Todo(1001, "World"),
                new Todo(1002, "Study"),
                new Todo(1003, "Java")
        );
    }

    @DeleteMapping("/todos/{id}")
    public long deleteTodo(@PathVariable long id) {
        return id;
    }

    @PostMapping("/todos")
    public Todo createTodo(@RequestBody Todo todo) {
        return Todo.builder()
                .id(2000)
                .content(todo.content())
                .build();
    }

    @PutMapping("/todos")
    public Todo updateTodo(Todo todo) {
        return Todo.builder()
                .id(todo.id())
                .content(todo.content())
                .build();
    }
}

3.2. HmacFilter Class

각 메소드 별로 어떤 기능을 수행하는지 살펴보겠습니다. 가독성을 위해 코드 설명은 주석으로 작성하였습니다.

package action.in.blog;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.apache.tomcat.util.buf.HexUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.crypto.Mac;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.Arrays;
import java.util.stream.Collectors;

@Component
public class HmacFilter extends OncePerRequestFilter {

    private static final String ALGORITHM = "HmacSHA256";
    private final String hmacSecret;

    public HmacFilter(@Value("${hmac.secret}") String hmacSecret) {
        this.hmacSecret = hmacSecret;
    }

    // JSON 메시지를 추출합니다.
    // application/json 타입의 메시지는 입력 스트림(stream)에 저장되어 있습니다.
    private String getJsonMessage(HttpServletRequest request) {
        try (
                OutputStream outputStream = new ByteArrayOutputStream();
                InputStream inputStream = request.getInputStream()
        ) {
            inputStream.transferTo(outputStream);
            return outputStream.toString();
        } catch (IOException ioException) {
            throw new RuntimeException(ioException);
        }
    }

    // 요청 객체에서 Form 메시지를 추출합니다.
    // application/x-www-form-urlencoded 타입의 메시지는 파라미터 프로퍼티를 통해 전달받습니다.
    private String getFormMessage(HttpServletRequest request) {
        var stringBuilder = new StringBuilder();
        var parameterMap = request.getParameterMap();
        for (String key : parameterMap.keySet()) {
            var values = parameterMap.get(key);
            stringBuilder
                    .append(
                            Arrays.stream(values)
                                    .map(value -> String.format("%s=%s", key, value))
                                    .collect(Collectors.joining("&"))
                    ).append("&");
        }
        if (stringBuilder.length() <= 0) {
            return "";
        }
        return stringBuilder.deleteCharAt(stringBuilder.length() - 1).toString();
    }

    // Content-Type 헤더 값에 따라 적절한 메시지를 반환합니다.
    private String getMessage(HttpServletRequest request) {
        if (MediaType.APPLICATION_FORM_URLENCODED_VALUE.equalsIgnoreCase(request.getContentType())) {
            return getFormMessage(request);
        }
        return getJsonMessage(request);
    }

    // HMAC을 생성합니다.
    // 주입 받은 비밀 키와 미리 지정한 알고리즘을 사용합니다.
    private String getHmac(String message, String requestTimestamp) {
        SecretKey secretKey = new SecretKeySpec(hmacSecret.getBytes(StandardCharsets.UTF_8), ALGORITHM);
        Mac hashFunction = null;
        try {
            hashFunction = Mac.getInstance(ALGORITHM);
            hashFunction.init(secretKey);
        } catch (NoSuchAlgorithmException | InvalidKeyException e) {
            throw new RuntimeException(e);
        }
        byte[] digestBytes = hashFunction.doFinal(
                String.format("%s%s", message, requestTimestamp).getBytes(StandardCharsets.UTF_8)
        );
        return HexUtils.toHexString(digestBytes);
    }

    // 필터링을 수행합니다.
    // 1. GET, DELETE 요청은 다음 필터로 진행합니다.
    // 2. 전달 받은 HMAC과 타임스탬프를 헤더에서 꺼냅니다.
    // 3. 요청 객체로부터 전달 받은 메시지를 꺼냅니다.
    // 4. 타임스탬프와 메시지를 조합한 값으로 HMAC을 구합니다.
    // 5. 생성한 HMAC과 전달받은 HMAC을 비교합니다.
    // 6. 정상적인 경우 다음 필터로 진행합니다.
    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {

        var wrappedRequest = new RepeatableReadRequest(request);
        var method = request.getMethod();
        if (HttpMethod.GET.name().equalsIgnoreCase(method) || HttpMethod.DELETE.name().equalsIgnoreCase(method)) {
            filterChain.doFilter(wrappedRequest, response);
            return;
        }

        var receivedHmac = request.getHeader("X-REQUEST-HMAC");
        var requestTimestamp = request.getHeader("X-REQUEST-TIMESTAMP");
        var message = getMessage(wrappedRequest);

        String hmac = getHmac(message, requestTimestamp);

        if (!hmac.equals(receivedHmac)) {
            response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
            return;
        }

        filterChain.doFilter(wrappedRequest, response);
    }
}

3.3. RepeatableReadRequest Class

application/json 타입 메시지는 입력 스트림으로 전달 받으므로 여러 번 읽을 수 없습니다. 필터에서 메시지를 읽는 경우 컨트롤러까지 메시지를 전달할 수 없습니다. 이 문제를 해결하기 위해 입력 스트림에서 읽은 바이트 배열은 임시 공간에 저장하여 재사용합니다.

package action.in.blog;

import io.micrometer.common.util.StringUtils;
import jakarta.servlet.ReadListener;
import jakarta.servlet.ServletInputStream;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletRequestWrapper;

import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;

public class RepeatableReadRequest extends HttpServletRequestWrapper {

    private final Charset encoding;
    private final byte[] rawBytes;

    public RepeatableReadRequest(HttpServletRequest request) {
        super(request);
        String characterEncoding = request.getCharacterEncoding();
        if (StringUtils.isBlank(characterEncoding)) {
            characterEncoding = StandardCharsets.UTF_8.name();
        }
        encoding = Charset.forName(characterEncoding);
        try {
            rawBytes = request.getInputStream().readAllBytes();
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    @Override
    public ServletInputStream getInputStream() throws IOException {
        var byteInputStream = new ByteArrayInputStream(rawBytes);
        return new ServletInputStream() {
            @Override
            public boolean isFinished() {
                return false;
            }

            @Override
            public boolean isReady() {
                return false;
            }

            @Override
            public void setReadListener(ReadListener listener) {
            }

            @Override
            public int read() throws IOException {
                return byteInputStream.read();
            }
        };
    }

    @Override
    public BufferedReader getReader() throws IOException {
        return new BufferedReader(new InputStreamReader(this.getInputStream(), this.encoding));
    }
}

4. Test

송신자 역할은 테스트 코드로 대체합니다. 필터 테스트를 위해 @WebMvcTest 애너테이션을 사용합니다. 다음과 같은 케이스들을 테스트하였습니다.

  • GET 요청은 정상적으로 응답을 받는다.
  • DELETE 요청은 정상적으로 응답을 받는다.
  • POST 요청은 다음과 같은 케이스를 확인합니다.
    • 동일한 메시지로 요청하는 경우 정상적으로 응답을 받는다.
    • 변경된 메시지로 요청하는 경우 400(bad request) 응답을 받는다.
  • PUT 요청은 다음과 같은 케이스를 확인합니다.
    • 동일한 메시지로 요청하는 경우 정상적으로 응답을 받는다.
    • 변경된 메시지로 요청하는 경우 400(bad request) 응답을 받는다.
package action.in.blog;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.tomcat.util.buf.HexUtils;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.web.servlet.MockMvc;

import javax.crypto.Mac;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@WebMvcTest
@TestPropertySource(
        properties = {"hmac.secret=hmac-secret"}
)
class ActionInBlogApplicationTests {

    @Autowired
    MockMvc mockMvc;

    private String getHmac(String message, long unixTimestamp) {
        SecretKey secretKey = new SecretKeySpec("hmac-secret".getBytes(StandardCharsets.UTF_8), "HmacSHA256");
        Mac hashFunction = null;
        try {
            hashFunction = Mac.getInstance("HmacSHA256");
            hashFunction.init(secretKey);
        } catch (NoSuchAlgorithmException | InvalidKeyException e) {
            throw new RuntimeException(e);
        }
        byte[] digestBytes = hashFunction.doFinal(String.format("%s%s", message, unixTimestamp).getBytes(StandardCharsets.UTF_8));
        return HexUtils.toHexString(digestBytes);
    }

    @Test
    void pass_hmac_filter_when_get_method() throws Exception {

        mockMvc.perform(get("/todos"))
                .andExpect(jsonPath("$[0].content").value("Hello"))
                .andExpect(jsonPath("$[1].content").value("World"))
                .andExpect(jsonPath("$[2].content").value("Study"))
                .andExpect(jsonPath("$[3].content").value("Java"))
        ;
    }

    @Test
    void pass_hmac_filter_when_delete_method() throws Exception {

        mockMvc.perform(delete("/todos/100001"))
                .andExpect(jsonPath("$").value(100001));
    }

    @Test
    void pass_hmac_filter_when_post_method() throws Exception {

        var objectMapper = new ObjectMapper();
        var currentTimestamp = Instant.now().getEpochSecond();
        var message = objectMapper.writeValueAsString(
                Todo.builder()
                        .id(0)
                        .content("This is new todo")
                        .build()
        );


        mockMvc.perform(
                        post("/todos")
                                .contentType(MediaType.APPLICATION_JSON)
                                .header("X-REQUEST-TIMESTAMP", currentTimestamp)
                                .header("X-REQUEST-HMAC", getHmac(message, currentTimestamp))
                                .content(message)
                )
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.id").value(2000))
                .andExpect(jsonPath("$.content").value("This is new todo"))
        ;
    }

    @Test
    void bad_request_when_changed_message() throws Exception {

        var objectMapper = new ObjectMapper();
        var currentTimestamp = Instant.now().getEpochSecond();
        var message = objectMapper.writeValueAsString(
                Todo.builder()
                        .id(0)
                        .content("This is new todo")
                        .build()
        );
        var changeMessage = objectMapper.writeValueAsString(
                Todo.builder()
                        .id(1)
                        .content("This is new todo")
                        .build()
        );


        mockMvc.perform(
                        post("/todos")
                                .contentType(MediaType.APPLICATION_JSON)
                                .header("X-REQUEST-TIMESTAMP", currentTimestamp)
                                .header("X-REQUEST-HMAC", getHmac(message, currentTimestamp))
                                .content(changeMessage)
                )
                .andExpect(status().isBadRequest())
        ;
    }

    @Test
    void pass_hmac_filter_when_put_method() throws Exception {

        var currentTimestamp = Instant.now().getEpochSecond();
        var message = "id=2000&content=This is update todo";


        mockMvc.perform(
                        put("/todos")
                                .contentType(MediaType.APPLICATION_FORM_URLENCODED)
                                .header("X-REQUEST-TIMESTAMP", currentTimestamp)
                                .header("X-REQUEST-HMAC", getHmac(message, currentTimestamp))
                                .content(message)
                )
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.id").value(2000))
                .andExpect(jsonPath("$.content").value("This is update todo"))
        ;
    }

    @Test
    void bad_request_when_changed_message_form_url_encoded() throws Exception {

        var currentTimestamp = Instant.now().getEpochSecond();
        var message = "id=2000&content=This is update todo";
        var changedMessage = "id=2001&content=This is update todo";


        mockMvc.perform(
                        put("/todos")
                                .contentType(MediaType.APPLICATION_FORM_URLENCODED)
                                .header("X-REQUEST-TIMESTAMP", currentTimestamp)
                                .header("X-REQUEST-HMAC", getHmac(message, currentTimestamp))
                                .content(changedMessage)
                )
                .andExpect(status().isBadRequest())
        ;
    }
}

TEST CODE REPOSITORY

REFERENCE

댓글남기기