SigV4 검증기를 Go로 밑바닥부터

Cloud Control Plane
  1. 1. AWS CLI는 무엇을 보내는가
  2. 2. CLI가 속아 넘어가는 순간
  3. 3. IAM과 STS를 나누다
  4. 4. SigV4 검증기를 Go로 밑바닥부터
전체 4편
4 / 4

1편에서 aws sts get-caller-identity 한 줄이 보내는 요청을 통째로 캡처했다. 본문에는 Action=GetCallerIdentity가 폼으로 실려 있었고, Authorization 헤더에는 액세스키 ID·서명 범위·서명값이 들어 있었다. 그리고 한 가지를 확인했다 — 시크릿 키는 그 어디에도 없다.

그럼 시크릿을 받지도 않는데 서버는 어떻게 “이 요청을 보낸 사람이 시크릿을 아는 본인"이라고 판정하나. 이번 편은 그 검증을 실제로 짠다. 캡처한 서명이 진짜 맞는 서명인지, 서버가 똑같은 계산을 재현해서 확인하는 과정이다.

서버가 하는 일은 “따라 계산하기"다

SigV4의 아이디어는 1편에서 정리한 그대로다. 클라이언트는 시크릿으로 요청 내용을 버무려 서명이라는 결과 하나를 만들어 보낸다. 서버는 액세스키 ID로 자기 저장소에서 같은 시크릿을 찾아, 클라이언트가 한 것과 똑같은 계산을 다시 해본다. 두 결과가 같으면 통과다.

여기서 중요한 건 방향이 한쪽뿐이라는 점이다. 서버는 서명을 “풀어보는” 게 아니다. 서명은 해시(HMAC-SHA256)의 결과라 되돌릴 수 없다. 서버가 할 수 있는 건 오직 같은 재료로 같은 계산을 반복해서, 나온 값이 클라이언트가 보낸 값과 일치하는지 보는 것뿐이다. 그래서 이 계산을 바이트 하나 틀리지 않게 재현하는 게 검증의 전부다. 재료도, 순서도, 공백 하나까지 클라이언트와 똑같아야 한다.

계산은 크게 세 단계다. (1) 요청을 정해진 규칙으로 한 덩어리 문자열로 정규화하고(canonical request), (2) 거기에 알고리즘·시각·범위를 붙여 “서명할 문자열"을 만들고(string to sign), (3) 시크릿에서 파생한 키로 그 문자열을 HMAC한다. 하나씩 본다.

1단계 — Canonical Request

같은 요청이라도 헤더 순서나 대소문자, 공백이 조금씩 다를 수 있다. 그대로 서명하면 클라이언트와 서버가 미세하게 다른 문자열을 서명해 절대 일치하지 않는다. 그래서 SigV4는 요청을 정규 형식(canonical form) 하나로 눌러 담는다. 메서드, 경로, 쿼리, 서명 대상 헤더들, 헤더 목록, 그리고 본문 해시를 정해진 순서로 줄바꿈해 이어 붙인다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
func canonicalRequest(r *http.Request, signedHeaders []string, payloadHash string) string {
	var b strings.Builder
	b.WriteString(r.Method)          // POST
	b.WriteByte('\n')
	b.WriteString(escapedPathOr("/", r))
	b.WriteByte('\n')
	b.WriteString(canonicalQuery(r)) // 키로 정렬, 공백은 %20
	b.WriteByte('\n')
	for _, name := range signedHeaders {
		b.WriteString(name + ":" + canonicalHeaderValue(r, name) + "\n")
	}
	b.WriteByte('\n')
	b.WriteString(strings.Join(signedHeaders, ";"))
	b.WriteByte('\n')
	b.WriteString(payloadHash)
	return b.String()
}

디테일이 곧 함정이다. 헤더 값은 앞뒤 공백을 자르고 내부 연속 공백을 하나로 줄인다. 쿼리스트링은 키로 정렬하고 공백을 +가 아니라 %20으로 인코딩한다. host 헤더는 r.Header가 아니라 r.Host에서 꺼내야 한다(Go가 Host를 헤더 맵에 넣지 않기 때문이다). 이 중 하나만 어긋나도 canonical request가 달라지고, 최종 서명이 통째로 달라진다. SigV4가 “까다롭다"고 느껴지는 이유의 대부분이 이 1단계에 몰려 있다.

맨 마지막 줄 payloadHash본문 전체의 SHA-256이라는 점을 기억해 두자. 본문이 canonical request 안에 해시로 박혀 들어간다. 뒤에서 다시 쓴다.

2단계 — String to Sign

canonical request를 통째로 한 번 더 해시해서, 알고리즘 이름·요청 시각·서명 범위와 함께 네 줄짜리 문자열로 묶는다.

1
2
3
4
5
6
toSign := strings.Join([]string{
	Algorithm,                    // "AWS4-HMAC-SHA256"
	r.Header.Get("X-Amz-Date"),   // "20260705T112547Z"
	auth.Scope(),                 // "20260705/ap-northeast-2/sts/aws4_request"
	sha256Hex([]byte(canonical)), // 위에서 만든 canonical request의 해시
}, "\n")

여기서 X-Amz-Date가 서명 문자열 안에 들어간다. 1편에서 “이 시각에 서명이 묶인다"고 했던 게 이 줄이다. 요청 시각이 바뀌면 string to sign이 바뀌고 서명이 깨진다. 그래서 어제 캡처한 요청을 오늘 그대로 재전송해도 통하지 않는다.

3단계 — 서명 키 파생 체인

이제 이 문자열을 HMAC할 키가 필요하다. 그런데 시크릿을 곧바로 키로 쓰지 않는다. 시크릿에서 네 번의 HMAC을 거쳐 파생한 키를 쓴다.

1
2
3
4
5
6
func signingKey(secret, date, region, service string) []byte {
	key := hmacSHA256([]byte("AWS4"+secret), []byte(date)) // kDate
	key = hmacSHA256(key, []byte(region))                  // kRegion
	key = hmacSHA256(key, []byte(service))                 // kService
	return hmacSHA256(key, []byte("aws4_request"))         // kSigning
}

시크릿 → kDate → kRegion → kService → kSigning. 왜 이렇게 하나. 파생된 키가 날짜·리전·서비스에 종속되기 때문이다. 오늘 ap-northeast-2sts용으로 파생한 kSigning은 딱 그 조합에서만 유효하다. 만에 하나 이 파생 키 하나가 새어 나가도, 다른 날짜나 다른 서비스에는 쓸 수 없다. 원본 시크릿을 매번 직접 쓰는 대신, 좁은 범위로 한정된 일회용에 가까운 키를 만들어 쓰는 것이다. 1편에서 본 Credential의 범위(20260705/ap-northeast-2/sts/aws4_request)가 바로 이 파생 체인의 입력이었다.

마지막으로 이 kSigning으로 string to sign을 HMAC하면 64자리 16진수 서명이 나온다. 이게 Authorization 헤더의 Signature=와 같아야 한다.

1
2
key := signingKey(secret, auth.Date, auth.Region, auth.Service)
return hex.EncodeToString(hmacSHA256(key, []byte(toSign)))

비교는 상수시간으로

계산이 끝나면 마지막은 비교다. 그런데 그냥 ==로 문자열을 비교하지 않는다.

1
2
3
if !hmac.Equal(want, got) {
	return ErrSignatureMismatch
}

hmac.Equal은 두 바이트열을 상수시간(constant-time) 으로 비교한다. 보통의 문자열 비교는 첫 글자가 다르면 즉시 멈춘다. 그러면 “몇 번째 글자에서 틀렸나"가 걸린 시간으로 새어 나가고, 공격자가 그 시간차를 재서 서명을 한 글자씩 맞춰 갈 여지가 생긴다(타이밍 공격). 상수시간 비교는 일치하든 아니든 항상 끝까지 훑어서 그 정보를 흘리지 않는다. 서명 검증처럼 비밀에 관계된 비교에서는 습관적으로 이걸 쓴다.

그런데, 내 검증기가 맞는지는 누가 검증하나

여기까지 짜면 테스트를 붙이고 싶어진다. 자연스러운 방법은 이렇다 — 테스트 안에서 시크릿으로 서명을 하나 만들고(내 서명기), 그걸 내 검증기에 넣어 통과하는지 본다.

이 테스트는 항상 통과한다. 그리고 바로 그게 함정이다.

내 서명기와 내 검증기가 같은 실수를 공유하면, 예컨대 둘 다 헤더 정렬을 빼먹었다면, 둘은 사이좋게 틀린 채로 서로를 인정한다. 테스트는 초록불인데 진짜 AWS CLI가 보낸 요청은 죄다 거부하는 검증기가 만들어진다. 자기가 만든 답으로 자기 채점을 하니 틀려도 알 길이 없다.

그래서 테스트 벡터를 바깥에서 가져왔다. 1편에서 캡처한, 진짜 AWS CLI v2가 만든 서명 두 개다.

1
2
3
4
5
6
7
8
9
// 실제 aws CLI v2 요청에서 캡처 (가짜 테스트 자격증명).
//   AWS_ACCESS_KEY_ID=MINCLOUDTESTKEY0000A
//   AWS_SECRET_ACCESS_KEY=mincloud-test-secret-not-real
var capturedRequests = []struct {
	name, amzDate, signature string
}{
	{"first capture", "20260705T112547Z", "285b60204ba0de2785782461e04d6aa0c962ec010b8196276f4a1112306dab85"},
	{"second capture", "20260705T112814Z", "3e4361b978419419ae9c741d1c518fcf2930d8afdcaed1256962637320bb3984"},
}

(여기 키·시크릿은 전부 가짜 테스트 값이다. MINCLOUDTESTKEY0000A, mincloud-test-secret-not-real — 실제 AWS에서는 아무 데도 통하지 않는다.)

이제 테스트의 의미가 완전히 달라진다. 내 ComputeSignature가 뱉은 값이 이 문자열과 같다는 건, 내 구현이 AWS의 SigV4 구현과 바이트 단위로 일치한다는 뜻이다. 서명 두 개는 시각(X-Amz-Date)만 다르다. 같은 재료로 시각만 바꿨을 때 두 서명이 다르게, 그러나 각각 정확히 나온다면 시각이 계산에 제대로 반영됐다는 것까지 한 번에 검증된다.

1
2
3
if got := ComputeSignature(r, auth, []byte(testBody), testSecretKey); got != tc.signature {
	t.Errorf("ComputeSignature = %s, want %s", got, tc.signature)
}

이 한 줄이 초록불이면, 우리 검증기는 더 이상 “내가 나를 믿는” 검증기가 아니다. AWS가 기준점이다.

본문을 건드리면 서명이 깨진다

1단계에서 본문 전체의 해시가 canonical request 맨 끝에 박힌다고 했다. 그 효과를 테스트로 못박아 둔다. 캡처한 서명은 그대로 두고, 본문만 GetCallerIdentity에서 AssumeRole로 바꿔 검증해 본다.

1
2
3
4
tampered := []byte("Action=AssumeRole&Version=2011-06-15")
if err := Verify(r, auth, tampered, testSecretKey); err != ErrSignatureMismatch {
	t.Errorf("tampered body should fail")
}

결과는 ErrSignatureMismatch. 본문이 서명 안에 해시로 묶여 있으니, 중간에서 누가 GetCallerIdentityAssumeRole로 바꿔치기하면 서명이 곧바로 깨진다. 서명은 “누가 보냈나"만이 아니라 “내용이 그대로인가” 까지 함께 보증한다. 마찬가지로 잘못된 시크릿(wrong-secret)으로 검증하면 파생 키가 달라져 역시 ErrSignatureMismatch가 난다.

부록 — openssl로 손 서명

이 계산에 마법은 없다. docs/sign-by-hand.sh는 전체 과정을 셸 스크립트로, opensslshasum만으로 재현한다. 본문 해시 → canonical request → string to sign → 파생 체인(kDatekRegionkServicekSigning) → 최종 서명을 한 줄씩 만들어 curl에 실어 보낸다. Go 코드가 하는 일과 한 단계씩 정확히 대응한다. SigV4를 손으로 한 번 따라 서명해 보면 “왜 이 순서인가"가 몸에 남는다.

다음 편

서명 검증은 이제 실제 AWS와 바이트 단위로 맞는다. 하지만 서명이 맞는 것과, CLI가 만족하는 응답을 돌려주는 것은 다른 문제다. 서버는 아직 액세스키 ID로 시크릿을 어떻게 찾는지(credstore), 신원을 CLI가 알아듣는 XML로 어떻게 돌려주는지(STS GetCallerIdentity), 그리고 서명이 틀렸을 때 진짜 AWS와 똑같은 에러 코드로 어떻게 거절하는지를 정하지 않았다.

다음 편에서 그 세 조각을 붙인다. 그리고 aws sts get-caller-identity가 우리 XML을 아무 불평 없이 파싱해 신원을 그대로 리턴하는 순간 — CLI가 우리를 진짜 AWS로 착각하는 순간 — 을 본다.