HMAC 기반 Presigned URL을 직접 설계하며 배운 것

사용자가 스토리지 서버를 거치지 않고 파일을 업로드하거나 다운로드하려면 짧은 시간만 유효한 파일 접근 권한을 URL에 담아야 합니다. 처음에는 경로와 만료 시각을 HMAC으로 서명하면 충분하다고 생각했지만, 실제로는 서명 대상과 검증 순서까지 하나의 계약으로 맞춰야 했습니다.

HTTP method와 인코딩 순서가 달라지자 같은 요청의 서명이 어긋났고, 경로만 서명한 URL은 다른 의미로 재사용될 수 있었습니다.

경로와 만료 시각만으로는 요청을 고정할 수 없었다

단순히 경로와 만료 시각만 서명하면 HTTP 메서드나 콘텐츠 속성을 바꿔 같은 URL을 다른 용도로 사용할 수 있었습니다. URL 인코딩 방식이 클라이언트와 서버에서 다르면 정상 요청도 서명 불일치가 났습니다. 서명이 유효하다는 사실과 사용자가 해당 파일에 접근할 권한이 있다는 사실도 구분해야 했습니다.

Canonical request에 포함한 값

canonicalization 규칙은 구현 코드보다 먼저 테스트 벡터로 고정했습니다. 공백, 한글 경로, 중복 슬래시, 쿼리 순서가 다른 요청을 클라이언트와 서버 양쪽에서 계산해 같은 바이트열이 나오는지 비교했습니다. 서명 불일치가 생기면 HMAC 결과만 보지 않고 어느 정규화 단계에서 입력이 달라졌는지 확인할 수 있게 했습니다.

  • 메서드, 정규화 경로, 만료 시각, 필요한 헤더를 canonical request에 포함합니다.
  • 서명 비교는 timing-safe 연산을 사용하고 만료 검사를 먼저 수행합니다.
  • Presigned URL 발급 시점에 리소스 권한을 검증하고 수명은 짧게 유지합니다.

발급과 검증의 책임을 나눴다

서버는 요청 요소를 고정된 순서와 구분자로 연결한 canonical string을 만듭니다. 비밀 키로 HMAC을 계산하고 key id, expiry, signature를 쿼리 파라미터에 넣습니다.

스토리지 엔드포인트는 동일한 정규화 규칙으로 문자열을 재생성하고 서명을 비교합니다. 경로 디코딩을 여러 번 하지 않고 허용된 clock skew와 최대 만료 시간을 제한합니다. 업로드에서는 파일 크기와 content type 같은 정책을 서명에 포함하거나 별도 ticket과 연결해 예상하지 않은 객체가 저장되지 않게 합니다.

val canonical = listOf(
  method.uppercase(), normalizePath(path),
  expiresAt.epochSecond.toString(), sha256(body), tenantId
).joinToString("\n")
val signature = hmacSha256(secret, canonical).base64Url()
PUT
/v1/resources/group-a/model.bin
1786964400
e3b0c44298fc...
group-a
if (clock.instant().isAfter(expiresAt.plusSeconds(allowedSkew))) reject()
val key = keyProvider.find(keyId) ?: reject()
val expected = hmacSha256(key.secret, canonicalRequest(request))
if (!MessageDigest.isEqual(expected, supplied)) reject()

정상 요청보다 변형된 요청을 먼저 시험했다

  • 프록시가 경로를 decode하는 순서가 달라 동일 요청의 서명이 불일치하는 문제.
  • 단순 문자열 비교가 timing side channel을 만드는 문제.
  • 긴 만료 시간과 로그에 남은 URL이 사실상 장기 credential이 되는 문제.

로그에 남겨야 할 것과 남기지 말아야 할 것

서명 실패를 진단하려고 원본 URL이나 signature를 그대로 기록하면 짧은 수명의 credential을 로그에 복제하는 셈이 됩니다. 대신 key id, 만료 여부, 정규화 단계, 요청을 연결할 식별자만 남겼습니다. 권한 거부와 암호학적 검증 실패도 별도 사유로 집계해 잘못된 정책과 클라이언트 구현 오류를 구분했습니다.

  • 공백, Unicode, 슬래시, 쿼리 순서가 다른 테스트 벡터를 공유합니다.
  • 비밀 키 회전을 위해 key id와 복수 검증 키 기간을 지원합니다.
  • 서명 실패 로그에 원문 서명이나 비밀 정보를 남기지 않습니다.
  • 공백·한글·슬래시·쿼리 순서를 포함한 canonicalization golden test를 둡니다.
  • 만료 직전과 clock skew 경계에서 양쪽 서버의 판정이 같은지 확인합니다.
  • 서명 URL과 secret이 access log나 오류 추적 도구에 남지 않게 마스킹합니다.

직접 서명하며 감수한 비용

애플리케이션 서버가 대용량 파일 바이트를 중계하지 않아도 짧은 권한으로 직접 전송할 수 있었습니다. 서명과 도메인 권한을 분리하면서 암호학적으로 유효하지만 허용되지 않은 접근도 정책 계층에서 차단할 수 있었습니다.

직접 HMAC 방식을 유지하는 만큼 클라이언트별 인코딩 차이와 key rotation은 계속 테스트해야 합니다. 플랫폼이 제공하는 검증된 Presigned URL로 요구사항을 충족할 수 있다면 자체 프로토콜보다 그쪽이 더 안전할 수 있습니다.

서명과 권한을 분리한 이유

이 작업에서 어려웠던 부분은 HMAC 계산 자체가 아니라 “같은 요청”의 정의였습니다. method, 정규화된 경로, 만료 시각과 필요한 헤더 중 하나라도 서명 밖에 남으면 URL은 예상보다 넓은 권한이 됩니다.

서명 검증이 성공한 뒤에도 사용자의 파일 접근 권한은 별도로 확인했습니다. 암호학적으로 유효하다는 사실과 업무적으로 허용된 요청이라는 사실을 분리한 것이 가장 중요한 선택이었습니다.

댓글

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다