<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>posts on J2eff's Garage</title><link>https://blog.j2eff.me/post/</link><description>Recent content in posts on J2eff's Garage</description><generator>Hugo -- gohugo.io</generator><language>ko-KR</language><lastBuildDate>Sun, 05 Jul 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://blog.j2eff.me/post/index.xml" rel="self" type="application/rss+xml"/><item><title>AWS CLI는 무엇을 보내는가</title><link>https://blog.j2eff.me/p/aws-cli%EB%8A%94-%EB%AC%B4%EC%97%87%EC%9D%84-%EB%B3%B4%EB%82%B4%EB%8A%94%EA%B0%80/</link><pubDate>Sun, 05 Jul 2026 00:00:00 +0000</pubDate><guid>https://blog.j2eff.me/p/aws-cli%EB%8A%94-%EB%AC%B4%EC%97%87%EC%9D%84-%EB%B3%B4%EB%82%B4%EB%8A%94%EA%B0%80/</guid><description>&lt;!--
제목 후보 (Jeff 선택용):
 1. AWS CLI는 무엇을 보내는가 ← 현재 가안
 2. 서명을 검증하기 전에, 요청부터 들여다본다
 3. 액세스키는 아이디, 시크릿은 비밀번호 — AWS 요청 한 개 해부
--&gt;
&lt;p&gt;AWS를 연습하려면 늘 같은 벽에 부딪힌다. 계정을 열고, 카드를 등록하고, 뭔가를 잘못 켜두면 청구서가 날아온다. &lt;code&gt;aws&lt;/code&gt; CLI 명령 하나, Terraform &lt;code&gt;apply&lt;/code&gt; 한 번이 실제 리소스를 만들고 실제 돈이 된다. 마음 편히 이것저것 눌러보기 어렵다.&lt;/p&gt;
&lt;p&gt;mincloud는 그 반대를 목표로 한다. &lt;strong&gt;AWS API와 호환되는 서버를 Go로 밑바닥부터 짜서, 로컬에서 AWS CLI·SDK·Terraform을 청구서 걱정 없이 돌리는 것.&lt;/strong&gt; 이미 LocalStack이 그 자리를 채우고 있지만, 여기서 하려는 건 완성품을 갖다 쓰는 게 아니라 그 안에서 무슨 일이 벌어지는지 한 겹씩 직접 구현하며 이해하는 것이다.&lt;/p&gt;
&lt;p&gt;&amp;ldquo;호환된다&amp;quot;는 말은 생각보다 단순하다. AWS CLI나 SDK가 진짜 AWS에 보내는 것과 &lt;strong&gt;똑같은 요청을 받아, 똑같은 형식의 응답을 돌려주면&lt;/strong&gt; 클라이언트 입장에선 상대가 진짜 AWS인지 아닌지 구분할 수 없다. 그러니 서버를 짜기 전에 먼저 &amp;ldquo;진짜 AWS에 보내던 요청이 정확히 어떻게 생겼는지&amp;quot;를 알아야 한다. 그게 이 편의 전부다.&lt;/p&gt;
&lt;p&gt;첫 목표는 아주 작게 잡았다. &lt;code&gt;aws sts get-caller-identity&lt;/code&gt; 한 줄이 &amp;ldquo;너는 이 계정의 이 사용자다&amp;quot;라는 신원을 리턴하게 만드는 것. 실제 AWS에서도 자격증명이 살아있는지 확인할 때 가장 먼저 때려보는 명령이다. 이게 되면 요청을 받고, 인증하고, 응답을 돌려주는 최소 왕복이 한 바퀴 완성된 셈이다.&lt;/p&gt;
&lt;p&gt;그런데 이 한 줄을 받아내려면 먼저 알아야 할 게 있다. &lt;strong&gt;CLI가 대체 무엇을 보내는가.&lt;/strong&gt;&lt;/p&gt;
&lt;h2 id="구현하기-전에-일단-들여다본다"&gt;구현하기 전에, 일단 들여다본다
&lt;/h2&gt;&lt;p&gt;문서로 SigV4를 공부할 수도 있다. 하지만 더 빠른 길이 있다. 요청을 그냥 받아서 그대로 찍어보는 것이다. 서명을 검증하지도, 응답을 제대로 만들지도 않는다. 딱 두 가지만 한다 — 들어온 요청을 로그로 덤프하고, 아무 말이나 돌려준다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;span class="lnt"&gt;12
&lt;/span&gt;&lt;span class="lnt"&gt;13
&lt;/span&gt;&lt;span class="lnt"&gt;14
&lt;/span&gt;&lt;span class="lnt"&gt;15
&lt;/span&gt;&lt;span class="lnt"&gt;16
&lt;/span&gt;&lt;span class="lnt"&gt;17
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;package&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;main&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;import&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;fmt&amp;#34;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;log&amp;#34;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;net/http&amp;#34;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;net/http/httputil&amp;#34;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;func&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="nx"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;HandleFunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;/&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kd"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ResponseWriter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;		&lt;/span&gt;&lt;span class="nx"&gt;dump&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;httputil&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;DumpRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// 헤더 + 본문 통째로&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;		&lt;/span&gt;&lt;span class="nx"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Printf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;\n%s&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;dump&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;		&lt;/span&gt;&lt;span class="nx"&gt;fmt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Fprintln&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;not implemented&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="nx"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Fatal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ListenAndServe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;:9900&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;핵심은 Go 표준 라이브러리의 &lt;code&gt;httputil.DumpRequest&lt;/code&gt;다. 요청 한 개를 헤더부터 본문까지 통째로 문자열로 만들어준다. 서버는 그걸 로그로 찍고, 본문에는 &lt;code&gt;not implemented&lt;/code&gt;만 적어 돌려준다.&lt;/p&gt;
&lt;p&gt;이제 CLI에게 진짜 AWS 대신 이 서버를 보라고 알려준다.&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;AWS_ACCESS_KEY_ID=MINCLOUDTESTKEY0000A \
AWS_SECRET_ACCESS_KEY=mincloud-test-secret-not-real \
aws sts get-caller-identity \
 --endpoint-url http://localhost:9900 \
 --region ap-northeast-2
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;code&gt;--endpoint-url&lt;/code&gt;은 CLI가 요청을 보낼 주소를 바꾼다. 자격증명은 환경변수로 넘긴다. &lt;strong&gt;여기 나오는 키와 시크릿은 전부 테스트용 가짜 값이다&lt;/strong&gt; — 실제 어디에도 통하지 않는다.&lt;/p&gt;
&lt;p&gt;CLI는 이렇게 답한다.&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;Unable to parse response (syntax error: line 1, column 0), invalid XML received.
b&amp;#39;not implemented\n&amp;#39;
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;당연하다. 우리는 XML이 아니라 &lt;code&gt;not implemented&lt;/code&gt;라는 평문을 돌려줬으니까. 이 에러는 마지막에 다시 볼 것이다. 지금 중요한 건 CLI가 응답을 받기 전에 &lt;strong&gt;보낸 것&lt;/strong&gt;, 즉 서버 로그에 찍힌 요청이다.&lt;/p&gt;
&lt;!-- screenshot: 왼쪽 터미널에 mincloud 덤프 서버 로그, 오른쪽 터미널에 aws CLI의 invalid XML 에러 --&gt;
&lt;h2 id="캡처된-요청"&gt;캡처된 요청
&lt;/h2&gt;&lt;p&gt;로그에 찍힌 요청은 이렇다. (읽기 좋게 &lt;code&gt;Authorization&lt;/code&gt; 한 줄만 여러 줄로 접었다.)&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;POST / HTTP/1.1
Host: localhost:9900
Accept-Encoding: identity
Authorization: AWS4-HMAC-SHA256
 Credential=MINCLOUDTESTKEY0000A/20260705/ap-northeast-2/sts/aws4_request,
 SignedHeaders=content-type;host;x-amz-date,
 Signature=285b60204ba0de2785782461e04d6aa0c962ec010b8196276f4a1112306dab85
Content-Length: 43
Content-Type: application/x-www-form-urlencoded; charset=utf-8
X-Amz-Date: 20260705T112547Z

Action=GetCallerIdentity&amp;amp;Version=2011-06-15
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;몇 가지가 눈에 띈다.&lt;/p&gt;
&lt;p&gt;먼저 맨 아래 본문. &lt;code&gt;Action=GetCallerIdentity&amp;amp;Version=2011-06-15&lt;/code&gt;, 이 한 줄이 곧 API 호출이다. &lt;code&gt;get-caller-identity&lt;/code&gt;라는 명령이 &lt;code&gt;Action=GetCallerIdentity&lt;/code&gt;라는 폼 데이터로 번역돼 본문에 실렸다. STS를 비롯한 오래된 AWS 서비스는 이렇게 &lt;strong&gt;Query protocol&lt;/strong&gt;을 쓴다 — HTTP 본문에 폼 인코딩된 &lt;code&gt;Action=...&lt;/code&gt;이 그대로 호출 이름이 된다. JSON도, REST 경로도 아니다. &lt;code&gt;POST /&lt;/code&gt; 하나에, 본문으로 &amp;ldquo;무엇을 할지&amp;quot;를 담는다.&lt;/p&gt;
&lt;p&gt;굳이 폼 인코딩과 &lt;code&gt;Action=&lt;/code&gt;이라니 낯설지만, STS·EC2 같은 초기 서비스가 이 방식으로 설계된 뒤 하위 호환을 위해 그대로 유지된 것이다. 우리 입장에선 오히려 다루기 쉽다. 본문을 폼으로 파싱해 &lt;code&gt;Action&lt;/code&gt; 값만 꺼내면 무슨 작업을 원하는지 바로 알 수 있다.&lt;/p&gt;
&lt;p&gt;그리고 &lt;code&gt;X-Amz-Date&lt;/code&gt;. 요청을 만든 시각이다. 뒤에서 서명이 이 시각에 묶이기 때문에, 오래된 요청을 그대로 복사해 재사용하지 못하게 하는 장치가 된다.&lt;/p&gt;
&lt;p&gt;하지만 이 요청의 핵심은 &lt;code&gt;Authorization&lt;/code&gt; 헤더다.&lt;/p&gt;
&lt;h2 id="authorization-헤더의-네-조각"&gt;Authorization 헤더의 네 조각
&lt;/h2&gt;&lt;p&gt;접었던 줄을 다시 한 줄로 펴 보면 이렇다.&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;Authorization: AWS4-HMAC-SHA256 Credential=MINCLOUDTESTKEY0000A/20260705/ap-northeast-2/sts/aws4_request, SignedHeaders=content-type;host;x-amz-date, Signature=285b60204ba0de...
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;맨 앞 &lt;code&gt;AWS4-HMAC-SHA256&lt;/code&gt;은 서명 방식의 이름이다. 그 뒤로 콤마로 구분된 세 덩어리가 온다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Credential&lt;/strong&gt; = &lt;code&gt;MINCLOUDTESTKEY0000A/20260705/ap-northeast-2/sts/aws4_request&lt;/code&gt;. 슬래시로 나뉜 첫 조각 &lt;code&gt;MINCLOUDTESTKEY0000A&lt;/code&gt;가 액세스키 ID다. 나머지 &lt;code&gt;20260705/ap-northeast-2/sts/aws4_request&lt;/code&gt;는 이 서명이 유효한 &lt;strong&gt;범위&lt;/strong&gt; — 날짜, 리전, 서비스, 그리고 고정 문자열 &lt;code&gt;aws4_request&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;SignedHeaders&lt;/strong&gt; = &lt;code&gt;content-type;host;x-amz-date&lt;/code&gt;. 이 요청의 어떤 헤더들이 서명 계산에 들어갔는지 적은 목록이다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Signature&lt;/strong&gt; = &lt;code&gt;285b60204ba0de...&lt;/code&gt;. 실제 서명값. 64자리 16진수, 즉 SHA-256 계열 HMAC의 결과다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;여기서 한 가지가 눈에 들어와야 한다. &lt;strong&gt;어디에도 시크릿 키가 없다.&lt;/strong&gt;&lt;/p&gt;
&lt;h2 id="키는-두-개-역할이-다르다"&gt;키는 두 개, 역할이 다르다
&lt;/h2&gt;&lt;p&gt;AWS 자격증명은 늘 쌍으로 온다. 액세스키 ID와 시크릿 액세스키. 이름이 비슷해서 둘 다 비밀 같지만, 역할은 아이디와 비밀번호만큼 다르다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;액세스키 ID&lt;/strong&gt; (&lt;code&gt;MINCLOUDTESTKEY0000A&lt;/code&gt;)는 아이디다. &amp;ldquo;누가 보냈나&amp;quot;를 밝힌다. 비밀이 아니기 때문에, 위에서 봤듯 요청에 &lt;strong&gt;평문으로 실려 온다&lt;/strong&gt;. 로그에 찍혀도, 도청당해도 그 자체로는 문제가 아니다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;시크릿 액세스키&lt;/strong&gt; (&lt;code&gt;mincloud-test-secret-not-real&lt;/code&gt;)는 비밀번호다. &amp;ldquo;정말 본인인가&amp;quot;를 증명한다. 그리고 &lt;strong&gt;절대 네트워크로 나가지 않는다.&lt;/strong&gt; 위 요청 어디에도 없다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;그럼 시크릿을 보내지도 않는데 서버는 어떻게 본인을 확인하나. 이게 SigV4의 핵심 아이디어다. 클라이언트는 시크릿으로 요청 내용을 버무려 &lt;strong&gt;서명&lt;/strong&gt;이라는 결과 하나를 만들어 보낸다. 서버는 액세스키 ID로 자기 저장소에서 같은 시크릿을 찾아, &lt;strong&gt;똑같은 계산을 다시 해본다.&lt;/strong&gt; 두 서명이 일치하면 &amp;ldquo;이 사람은 시크릿을 알고 있다 = 본인이다&amp;quot;라고 판정한다. 비밀번호를 주고받는 대신, 비밀번호를 아는 사람만 낼 수 있는 답을 맞춰보는 것이다.&lt;/p&gt;
&lt;p&gt;즉 서버가 할 일이 벌써 정해졌다. 액세스키 ID로 시크릿을 찾고, 클라이언트가 한 것과 &lt;strong&gt;똑같은 서명 계산을 재현하는 것.&lt;/strong&gt; 그 계산이 정확히 무엇인지는 다음 편에서 판다.&lt;/p&gt;
&lt;h2 id="signedheaders는-누가-정하나"&gt;SignedHeaders는 누가 정하나
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;SignedHeaders=content-type;host;x-amz-date&lt;/code&gt;를 다시 보자. 요청에는 이것 말고도 헤더가 더 있다. &lt;code&gt;Accept-Encoding&lt;/code&gt;도, &lt;code&gt;Content-Length&lt;/code&gt;도 버젓이 있는데 서명 목록엔 빠졌다. 누가 이 셋만 골랐나.&lt;/p&gt;
&lt;p&gt;두 주체가 관여한다. 먼저 &lt;strong&gt;호출하는 작업&lt;/strong&gt;이 자기가 중요하게 여기는 헤더를 정한다. &lt;code&gt;Content-Type&lt;/code&gt;은 본문을 어떻게 읽을지, &lt;code&gt;Host&lt;/code&gt;는 어느 서버로 가는지, &lt;code&gt;X-Amz-Date&lt;/code&gt;는 언제 만든 요청인지 — 바뀌면 요청의 의미가 달라지는 것들이다. 이런 건 서명에 넣어 잠근다.&lt;/p&gt;
&lt;p&gt;반대로 서명에서 일부러 빼는 헤더도 있다. &lt;code&gt;Accept-Encoding&lt;/code&gt;, &lt;code&gt;Content-Length&lt;/code&gt;, &lt;code&gt;User-Agent&lt;/code&gt; 같은 것들이다. 이들은 클라이언트와 서버 사이의 프록시·게이트웨이가 중간에 바꿔버릴 수 있다. 만약 이런 헤더까지 서명에 넣었다가 중간 장비가 값을 손대면, 내용은 멀쩡한데 서명만 깨져서 정상 요청이 거부된다. 그래서 서명기(SDK/CLI)는 이런 &amp;ldquo;중간에 변하는 헤더&amp;quot;를 블랙리스트로 두고 서명 대상에서 뺀다.&lt;/p&gt;
&lt;p&gt;정리하면 규칙은 단순하다. &lt;strong&gt;작업이 의미를 두는 헤더는 서명하고, 중간에서 변할 수 있는 헤더는 뺀다.&lt;/strong&gt; 그 결과가 &lt;code&gt;content-type;host;x-amz-date&lt;/code&gt;라는 목록이고, 서버는 이 목록에 적힌 헤더만, 적힌 순서대로 서명 계산에 넣어야 클라이언트와 같은 답이 나온다.&lt;/p&gt;
&lt;h2 id="다음-편-이-서명을-검증한다"&gt;다음 편: 이 서명을 검증한다
&lt;/h2&gt;&lt;p&gt;우리는 아직 한 줄의 검증 코드도 짜지 않았다. 그런데 요청 하나를 들여다본 것만으로 서버가 할 일의 윤곽이 다 드러났다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;본문의 &lt;code&gt;Action=&lt;/code&gt;을 읽어 무슨 작업인지 안다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Credential&lt;/code&gt;의 첫 조각으로 액세스키 ID를 얻고, 저장소에서 시크릿을 찾는다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;SignedHeaders&lt;/code&gt;에 적힌 헤더들과 본문으로, 클라이언트가 한 것과 같은 서명을 계산한다.&lt;/li&gt;
&lt;li&gt;그 결과가 &lt;code&gt;Signature&lt;/code&gt;와 같은지 본다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;그리고 아까 그 에러 — &lt;code&gt;invalid XML received&lt;/code&gt; — 가 마지막 할 일을 알려준다. 서명이 맞았다면, 이번엔 CLI가 알아들을 수 있는 XML로 신원을 돌려줘야 한다. 평문 &lt;code&gt;not implemented&lt;/code&gt;로는 안 된다.&lt;/p&gt;
&lt;p&gt;다음 편에서는 3번, &lt;strong&gt;서명 계산을 실제로 재현&lt;/strong&gt;한다. &lt;code&gt;AWS4-HMAC-SHA256&lt;/code&gt;이라는 이름 뒤에 어떤 계산이 숨어 있는지, 왜 시크릿을 곧바로 쓰지 않고 여러 단계를 거치는지, 그리고 Go 스무 줄 남짓이 실제 AWS CLI가 만든 서명과 바이트 단위로 똑같은 값을 뱉게 만드는 과정을 따라간다.&lt;/p&gt;</description></item><item><title>CLI가 속아 넘어가는 순간</title><link>https://blog.j2eff.me/p/cli%EA%B0%80-%EC%86%8D%EC%95%84-%EB%84%98%EC%96%B4%EA%B0%80%EB%8A%94-%EC%88%9C%EA%B0%84/</link><pubDate>Sun, 05 Jul 2026 00:00:00 +0000</pubDate><guid>https://blog.j2eff.me/p/cli%EA%B0%80-%EC%86%8D%EC%95%84-%EB%84%98%EC%96%B4%EA%B0%80%EB%8A%94-%EC%88%9C%EA%B0%84/</guid><description>&lt;!--
제목 후보 (Jeff 선택용):
 1. CLI가 속아 넘어가는 순간 ← 현재 가안
 2. aws sts get-caller-identity가 우리 XML을 믿는다
 3. 가짜 AWS에 진짜 CLI가 신원을 묻고, 답을 받는다
--&gt;
&lt;p&gt;2편에서 서명 검증을 실제 AWS CLI가 만든 서명과 바이트 단위로 맞췄다. 이제 서버는 &amp;ldquo;이 요청을 보낸 사람이 시크릿을 아는 본인&amp;quot;이라고 판정할 수 있다. 하지만 판정만으로는 아무 일도 일어나지 않는다. 액세스키 ID로 시크릿을 어디서 찾을지, 통과한 요청에 무엇을 돌려줄지, 틀린 요청은 어떻게 거절할지가 아직 비어 있다.&lt;/p&gt;
&lt;p&gt;이번 편은 그 세 조각을 붙여서, &lt;code&gt;aws sts get-caller-identity&lt;/code&gt;가 우리 서버에 신원을 묻고 답을 받아 가는 왕복을 완성한다.&lt;/p&gt;
&lt;h2 id="액세스키-id에서-시크릿으로--credstore"&gt;액세스키 ID에서 시크릿으로 — credstore
&lt;/h2&gt;&lt;p&gt;서명을 재현하려면 시크릿이 있어야 한다. 요청은 시크릿을 담아 오지 않는다(1편). 담아 오는 건 액세스키 ID뿐이다. 그러니 서버 어딘가에 &lt;strong&gt;액세스키 ID → 시크릿&lt;/strong&gt; 대응표가 있어야 한다. 그게 credstore다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;span class="lnt"&gt;12
&lt;/span&gt;&lt;span class="lnt"&gt;13
&lt;/span&gt;&lt;span class="lnt"&gt;14
&lt;/span&gt;&lt;span class="lnt"&gt;15
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Identity&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kd"&gt;struct&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="nx"&gt;Account&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="nx"&gt;UserID&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="nx"&gt;ARN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Credential&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kd"&gt;struct&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="nx"&gt;SecretAccessKey&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="nx"&gt;Identity&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Identity&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Store&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kd"&gt;interface&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="nf"&gt;Put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;accessKeyID&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Credential&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="nf"&gt;Lookup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;accessKeyID&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Credential&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;핵심은 액세스키 ID가 시크릿뿐 아니라 &lt;strong&gt;신원(Identity)까지&lt;/strong&gt; 함께 가리킨다는 점이다. 서명이 맞았다는 건 &amp;ldquo;이 액세스키 ID의 주인&amp;quot;임이 증명됐다는 뜻이고, 그 주인이 누구인지(어느 계정의 어느 사용자인지)가 곧 &lt;code&gt;get-caller-identity&lt;/code&gt;가 되돌려줄 답이다. 그래서 저장소의 값 하나에 시크릿과 신원이 같이 묶여 있다.&lt;/p&gt;
&lt;p&gt;지금은 개발용 자격증명 하나를 서버 기동 시 심어 둔다. 전부 잘 알려진 가짜 값이다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;span class="lnt"&gt;7
&lt;/span&gt;&lt;span class="lnt"&gt;8
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;MINCLOUDTESTKEY0000A&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;credstore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Credential&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="nx"&gt;SecretAccessKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;mincloud-test-secret-not-real&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="nx"&gt;Identity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;credstore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Identity&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;		&lt;/span&gt;&lt;span class="nx"&gt;Account&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;123456789012&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;		&lt;/span&gt;&lt;span class="nx"&gt;UserID&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;AIDALOUDTESTKEY0000A&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;		&lt;/span&gt;&lt;span class="nx"&gt;ARN&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;arn:aws:iam::123456789012:user/jeff&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;})&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;h2 id="인증-순서--어디서-거절하고-무엇을-돌려주나"&gt;인증 순서 — 어디서 거절하고, 무엇을 돌려주나
&lt;/h2&gt;&lt;p&gt;요청이 들어오면 검증은 정해진 순서로 진행된다. 각 단계에서 실패하면 &lt;strong&gt;진짜 AWS와 같은 에러 코드&lt;/strong&gt;로 즉시 거절한다. 이 코드들이 중요하다. CLI와 SDK는 이 코드를 보고 &amp;ldquo;자격증명이 잘못됐다&amp;quot;인지 &amp;ldquo;서명이 틀렸다&amp;quot;인지 스스로 판단하고 사용자에게 안내하기 때문이다. 코드가 다르면 클라이언트 입장에선 우리가 진짜 AWS와 다르게 행동하는 것이 된다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;cred&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Lookup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;AccessKeyID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nx"&gt;AuthError&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;InvalidClientTokenId&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;		&lt;/span&gt;&lt;span class="nx"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;The security token included in the request is invalid.&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// ...&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;sigv4&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;cred&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SecretAccessKey&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;!=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nx"&gt;AuthError&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;SignatureDoesNotMatch&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;		&lt;/span&gt;&lt;span class="nx"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;The request signature we calculated does not match the signature you provided...&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;두 갈래를 구분하는 게 핵심이다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;없는 액세스키&lt;/strong&gt; → &lt;code&gt;InvalidClientTokenId&lt;/code&gt;. 저장소에 그 키가 아예 없다. 시크릿을 찾을 수조차 없으니 서명을 재현할 대상이 없다. &amp;ldquo;네가 댄 신분증이 우리 명부에 없다&amp;quot;에 해당한다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;틀린 시크릿&lt;/strong&gt; → &lt;code&gt;SignatureDoesNotMatch&lt;/code&gt;. 키는 명부에 있는데, 그 키의 진짜 시크릿으로 재현한 서명이 요청에 담긴 서명과 다르다. &amp;ldquo;명부엔 있는데, 네가 낸 답이 틀렸다&amp;quot;에 해당한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;이 둘을 뭉뚱그려 하나로 거절하면 편하지만, 진짜 AWS는 이렇게 나눈다. 우리도 나눈다. 그래야 CLI가 두 상황을 다르게 안내한다.&lt;/p&gt;
&lt;h2 id="통과했다면--cli가-알아듣는-xml"&gt;통과했다면 — CLI가 알아듣는 XML
&lt;/h2&gt;&lt;p&gt;인증을 통과하면 본문의 &lt;code&gt;Action&lt;/code&gt;을 읽어 무슨 작업인지 정한다. &lt;code&gt;GetCallerIdentity&lt;/code&gt;면 credstore에서 딸려 온 신원을 XML로 직렬화해 돌려준다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;getCallerIdentityResult&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kd"&gt;struct&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="nx"&gt;Arn&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;`xml:&amp;#34;Arn&amp;#34;`&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="nx"&gt;UserID&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;`xml:&amp;#34;UserId&amp;#34;`&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="nx"&gt;Account&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;`xml:&amp;#34;Account&amp;#34;`&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;1편 끝에서 CLI가 &lt;code&gt;invalid XML received&lt;/code&gt;로 토라졌던 걸 기억할 것이다. 그때 우리는 &lt;code&gt;not implemented&lt;/code&gt;라는 평문을 돌려줬다. STS의 Query protocol은 응답도 정해진 XML 형태를 기대한다. 루트 엘리먼트 이름(&lt;code&gt;GetCallerIdentityResponse&lt;/code&gt;), 네임스페이스, &lt;code&gt;Arn&lt;/code&gt;/&lt;code&gt;UserId&lt;/code&gt;/&lt;code&gt;Account&lt;/code&gt;의 태그명 — 이게 CLI가 파싱해서 필드로 꺼내 쓸 계약이다. 태그명 하나만 어긋나도 CLI는 값을 못 찾는다.&lt;/p&gt;
&lt;h2 id="속아-넘어가는-순간"&gt;속아 넘어가는 순간
&lt;/h2&gt;&lt;p&gt;이제 붙여서 돌려 본다. 서버를 띄우고, 1편에서 쓴 그 명령을 다시 친다. 이번엔 &lt;code&gt;--endpoint-url&lt;/code&gt;이 진짜 mincloud를 가리킨다.&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;AWS_ACCESS_KEY_ID=MINCLOUDTESTKEY0000A \
AWS_SECRET_ACCESS_KEY=mincloud-test-secret-not-real \
aws sts get-caller-identity \
 --endpoint-url http://localhost:9900 \
 --region ap-northeast-2
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;CLI는 이렇게 답한다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-json" data-lang="json"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;UserId&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;AIDALOUDTESTKEY0000A&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;Account&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;123456789012&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;Arn&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;arn:aws:iam::123456789012:user/jeff&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;!-- screenshot: 왼쪽 mincloud 서버 로그(sts GetCallerIdentity by arn:aws:iam::...), 오른쪽 aws CLI가 JSON 신원을 출력 --&gt;
&lt;p&gt;이 출력이 이번 시리즈의 클라이맥스다. CLI는 자기가 진짜 AWS와 이야기하는지, 로컬호스트에 떠 있는 Go 프로그램 몇백 줄과 이야기하는지 &lt;strong&gt;전혀 구분하지 못한다.&lt;/strong&gt; 요청을 서명해 보냈고, 기대한 형태의 XML을 받아 파싱했고, 신원을 꺼내 사용자에게 보여줬다. CLI 입장에서 이건 그냥 성공한 AWS 호출이다.&lt;/p&gt;
&lt;p&gt;참고로 CLI는 XML을 받아 위처럼 JSON으로 바꿔 보여준다. 우리가 돌려준 건 XML이고, CLI가 그걸 파싱해 자기 출력 형식으로 옮긴 것이다. 서버 로그에는 &lt;code&gt;sts GetCallerIdentity by arn:aws:iam::123456789012:user/jeff&lt;/code&gt; 한 줄이 찍혀, 인증을 통과한 신원으로 응답이 나갔음을 보여준다.&lt;/p&gt;
&lt;h2 id="두-번째-증인--curl---aws-sigv4"&gt;두 번째 증인 — curl &lt;code&gt;--aws-sigv4&lt;/code&gt;
&lt;/h2&gt;&lt;p&gt;CLI 하나가 속았다고 &amp;ldquo;AWS와 호환된다&amp;quot;고 말하긴 이르다. AWS CLI에만 있는 버릇에 우연히 맞춘 것일 수도 있다. 그래서 &lt;strong&gt;완전히 독립적인 두 번째 클라이언트&lt;/strong&gt;로 같은 걸 시켜 본다. curl에는 SigV4 서명을 스스로 붙이는 &lt;code&gt;--aws-sigv4&lt;/code&gt; 옵션이 있다.&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;curl -s http://localhost:9900/ \
 --aws-sigv4 &amp;#34;aws:amz:ap-northeast-2:sts&amp;#34; \
 --user &amp;#34;MINCLOUDTESTKEY0000A:mincloud-test-secret-not-real&amp;#34; \
 -H &amp;#34;X-Amz-Date: $(date -u +%Y%m%dT%H%M%SZ)&amp;#34; \
 -H &amp;#34;Content-Type: application/x-www-form-urlencoded; charset=utf-8&amp;#34; \
 -d &amp;#34;Action=GetCallerIdentity&amp;amp;Version=2011-06-15&amp;#34;
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;curl은 AWS CLI와 코드 한 줄 공유하지 않는 별개 구현이다. 그 curl이 붙인 서명도 우리 검증기를 그대로 통과하고, 우리 XML을 받아 온다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-xml" data-lang="xml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;&amp;lt;GetCallerIdentityResponse&lt;/span&gt; &lt;span class="na"&gt;xmlns=&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;https://sts.amazonaws.com/doc/2011-06-15/&amp;#34;&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;lt;GetCallerIdentityResult&amp;gt;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;lt;Arn&amp;gt;&lt;/span&gt;arn:aws:iam::123456789012:user/jeff&lt;span class="nt"&gt;&amp;lt;/Arn&amp;gt;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;lt;UserId&amp;gt;&lt;/span&gt;AIDALOUDTESTKEY0000A&lt;span class="nt"&gt;&amp;lt;/UserId&amp;gt;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;lt;Account&amp;gt;&lt;/span&gt;123456789012&lt;span class="nt"&gt;&amp;lt;/Account&amp;gt;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;lt;/GetCallerIdentityResult&amp;gt;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;lt;ResponseMetadata&amp;gt;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;lt;RequestId&amp;gt;&lt;/span&gt;...&lt;span class="nt"&gt;&amp;lt;/RequestId&amp;gt;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;lt;/ResponseMetadata&amp;gt;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;&amp;lt;/GetCallerIdentityResponse&amp;gt;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;서로 무관한 두 클라이언트가 같은 규칙으로 서명하고, 우리 서버가 둘 다 통과시킨다는 것 — 이게 &amp;ldquo;호환된다&amp;quot;의 실질이다. 우리가 AWS CLI의 특정 동작을 흉내 낸 게 아니라, &lt;strong&gt;SigV4라는 공개된 규칙 자체를 맞게 구현&lt;/strong&gt;했다는 증거다.&lt;/p&gt;
&lt;h2 id="틀린-요청은-진짜처럼-거절한다"&gt;틀린 요청은 진짜처럼 거절한다
&lt;/h2&gt;&lt;p&gt;속이는 데 성공했으면, 반대로 &lt;strong&gt;속지 않는 것&lt;/strong&gt;도 진짜 AWS처럼 해야 한다. 시크릿을 일부러 틀리게 넣어 본다.&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;AWS_SECRET_ACCESS_KEY=this-is-the-wrong-secret \
aws sts get-caller-identity --endpoint-url http://localhost:9900 --region ap-northeast-2
&lt;/code&gt;&lt;/pre&gt;&lt;pre tabindex="0"&gt;&lt;code&gt;An error occurred (SignatureDoesNotMatch) when calling the GetCallerIdentity
operation: The request signature we calculated does not match the signature
you provided. Check your AWS Secret Access Key and signing method.
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;액세스키 ID는 명부에 있으니 시크릿을 찾아 서명을 재현했지만, 틀린 시크릿에서 파생한 서명은 요청의 서명과 다르다 → &lt;code&gt;SignatureDoesNotMatch&lt;/code&gt;. 명부에 없는 키를 대면 다른 코드가 나온다.&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;AWS_ACCESS_KEY_ID=AKIADOESNOTEXIST0000 \
AWS_SECRET_ACCESS_KEY=whatever \
aws sts get-caller-identity --endpoint-url http://localhost:9900 --region ap-northeast-2
&lt;/code&gt;&lt;/pre&gt;&lt;pre tabindex="0"&gt;&lt;code&gt;An error occurred (InvalidClientTokenId) when calling the GetCallerIdentity
operation: The security token included in the request is invalid.
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;두 에러 문구는 진짜 AWS가 같은 상황에서 내는 것과 같다. 통과도 AWS처럼, 거절도 AWS처럼 — 그래야 CLI가 우리를 진짜와 구분하지 못한다.&lt;/p&gt;
&lt;h2 id="다음-편--서비스를-나눈다"&gt;다음 편 — 서비스를 나눈다
&lt;/h2&gt;&lt;p&gt;지금은 서버 하나가 STS 요청을 다 처리한다. 그런데 진짜 AWS의 구조는 그렇지 않다. 신원과 자격증명을 관리하는 IAM은 계정 전체에 하나뿐인 &lt;strong&gt;글로벌&lt;/strong&gt; 서비스이고, 토큰을 발급하는 STS는 &lt;strong&gt;리전별&lt;/strong&gt;로 각자 엔드포인트가 있다. 2편에서 서명 범위(scope)에 리전과 서비스 이름이 박혀 있던 걸 떠올려 보면 — 서명은 이미 &amp;ldquo;어느 서비스, 어느 리전으로 가는 요청인가&amp;quot;를 알고 있었다.&lt;/p&gt;
&lt;p&gt;다음 편에서는 이 경계를 실제로 그린다. 하나의 서버를 IAM과 STS로 쪼개고, 한 서비스로 서명된 요청을 다른 서비스가 거절하게 만든다. 그리고 IAM으로 새 액세스키를 발급받아, 그 키로 STS에 신원을 물어보는 — 자격증명이 스스로 자격증명을 낳는 왕복을 만든다.&lt;/p&gt;</description></item><item><title>IAM과 STS를 나누다</title><link>https://blog.j2eff.me/p/iam%EA%B3%BC-sts%EB%A5%BC-%EB%82%98%EB%88%84%EB%8B%A4/</link><pubDate>Sun, 05 Jul 2026 00:00:00 +0000</pubDate><guid>https://blog.j2eff.me/p/iam%EA%B3%BC-sts%EB%A5%BC-%EB%82%98%EB%88%84%EB%8B%A4/</guid><description>&lt;!--
제목 후보 (Jeff 선택용):
 1. IAM과 STS를 나누다 ← 현재 가안
 2. 키를 발급하는 서비스와 검증하는 서비스
 3. 한 프로세스, 두 엔드포인트 — 서비스 경계를 긋다
--&gt;
&lt;p&gt;3편 끝에서 한 가지를 짚어뒀다. 지금까지 우리가 만든 건 STS 하나였지만, 진짜 AWS에서 IAM과 STS는 애초에 다른 서비스라는 것. 엔드포인트부터 다르다. IAM은 &lt;code&gt;iam.amazonaws.com&lt;/code&gt; 하나로 글로벌이고, STS는 &lt;code&gt;sts.ap-northeast-2.amazonaws.com&lt;/code&gt;처럼 리전마다 주소가 갈린다. 자격증명을 &lt;strong&gt;발급&lt;/strong&gt;하는 일과 자격증명을 &lt;strong&gt;검증&lt;/strong&gt;하는 일이 서로 다른 곳에서 벌어진다는 뜻이다.&lt;/p&gt;
&lt;p&gt;이번 편은 그 경계를 mincloud 안에 실제로 긋는다. 지금까지 STS 핸들러 하나가 하던 일을, IAM과 STS 두 개의 독립 서비스로 쪼갠다. 그리고 그 과정에서, 무심코 지나치면 인증이 통째로 뚫리는 함정 하나를 만난다.&lt;/p&gt;
&lt;h2 id="왜-나누나"&gt;왜 나누나
&lt;/h2&gt;&lt;p&gt;역할이 다르기 때문이다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;IAM은 컨트롤플레인이다.&lt;/strong&gt; 사용자를 만들고, 정책을 붙이고, 액세스키를 &lt;strong&gt;발급&lt;/strong&gt;한다. &amp;ldquo;누가 이 계정에서 무엇을 할 수 있는가&amp;quot;를 정의하는 쪽이다. 계정 전체에 하나뿐인 진실이라, 진짜 AWS에서도 리전을 나누지 않고 글로벌 엔드포인트 하나로 굴린다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;STS는 그 자격증명을 검증하고 신원을 돌려주는 쪽이다.&lt;/strong&gt; &lt;code&gt;GetCallerIdentity&lt;/code&gt;가 그 예다. 요청이 실제로 리소스를 때리는 데이터플레인 가까이에 있어야 하니, 리전별로 엔드포인트가 흩어져 있다.&lt;/p&gt;
&lt;p&gt;1편에서 봤던 SigV4 &lt;code&gt;Credential&lt;/code&gt; 범위를 다시 떠올려 보자. &lt;code&gt;.../ap-northeast-2/sts/aws4_request&lt;/code&gt; — 날짜, 리전, 그리고 &lt;strong&gt;서비스 이름&lt;/strong&gt;이 서명 범위에 박혀 있었다. AWS가 서비스를 이렇게 나눠 뒀기 때문에, 서명 자체가 &amp;ldquo;이 요청은 sts용이다&amp;quot;라고 자기 자신을 못 박는다. 우리가 이 구조를 흉내 내려면, 서버 쪽도 서비스별로 갈라야 앞뒤가 맞는다.&lt;/p&gt;
&lt;h2 id="한-프로세스-두-리스너"&gt;한 프로세스, 두 리스너
&lt;/h2&gt;&lt;p&gt;경계를 긋는다고 프로세스까지 둘로 쪼갤 필요는 없다. mincloud는 여전히 바이너리 하나다. 대신 서비스마다 독립된 &lt;code&gt;http.Handler&lt;/code&gt;를 두고, 서로 다른 포트에서 듣게 했다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;credstore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;New&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;errc&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;chan&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;go&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kd"&gt;func&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;errc&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Serve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stsLn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;sts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;verbose&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}()&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;go&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kd"&gt;func&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;errc&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Serve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;iamLn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;iam&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;verbose&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}()&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Fatal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="nx"&gt;errc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;&lt;code&gt;sts.Handler&lt;/code&gt;와 &lt;code&gt;iam.Handler&lt;/code&gt;는 각자 자기 패키지에 있고, 자기가 아는 &lt;code&gt;Action&lt;/code&gt;만 처리한다. STS는 &lt;code&gt;GetCallerIdentity&lt;/code&gt;, IAM은 &lt;code&gt;CreateAccessKey&lt;/code&gt;. 모르는 액션엔 &lt;code&gt;InvalidAction&lt;/code&gt;을 돌려준다.&lt;/p&gt;
&lt;p&gt;포인트는 두 핸들러가 &lt;strong&gt;같은 &lt;code&gt;store&lt;/code&gt;를 공유한다&lt;/strong&gt;는 것이다. IAM이 발급한 키를 STS가 검증하려면, 둘이 같은 자격증명 저장소를 봐야 한다. 지금은 프로세스가 하나라 그냥 인메모리 맵 하나를 두 goroutine이 나눠 쓰면 된다 — 그래서 &lt;code&gt;credstore.Store&lt;/code&gt;는 &lt;code&gt;sync.RWMutex&lt;/code&gt;로 동시 접근에 안전하게 만들어 뒀다.&lt;/p&gt;
&lt;p&gt;인증 로직은 공통이라 &lt;code&gt;service.Authenticate&lt;/code&gt; 한 곳에 모았다. 두 핸들러가 똑같이 이걸 부른다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;cred&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;authErr&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;service&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Authenticate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;serviceName&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;마지막 인자 &lt;code&gt;serviceName&lt;/code&gt;. &lt;code&gt;iam&lt;/code&gt; 패키지는 &lt;code&gt;&amp;quot;iam&amp;quot;&lt;/code&gt;을, &lt;code&gt;sts&lt;/code&gt; 패키지는 &lt;code&gt;&amp;quot;sts&amp;quot;&lt;/code&gt;를 넘긴다. 별것 아닌 문자열 같지만, 이 인자가 이번 편의 핵심이다.&lt;/p&gt;
&lt;h2 id="함정-서명은-스스로를-검증하지-않는다"&gt;함정: 서명은 스스로를 검증하지 않는다
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;Authenticate&lt;/code&gt; 안을 보면, 서명을 실제로 다시 계산해 맞춰보는 부분은 이렇다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;sigv4&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;cred&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SecretAccessKey&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;!=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nx"&gt;AuthError&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;Code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;SignatureDoesNotMatch&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;&lt;code&gt;Verify&lt;/code&gt;는 클라이언트가 했을 서명 계산을 그대로 재현해서, 결과가 요청에 실린 서명과 같은지 본다. 그런데 그 계산에 들어가는 서명 키(signing key)가 어떻게 만들어지는지 보자.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;signingKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;secretAccessKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Region&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Service&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;&lt;code&gt;auth.Service&lt;/code&gt; — 이건 우리가 아는 서비스 이름이 아니다. &lt;strong&gt;요청의 &lt;code&gt;Authorization&lt;/code&gt; 헤더에서 파싱해 온, 클라이언트가 적어 넣은 값&lt;/strong&gt;이다. 서명 키가 이 값으로 파생된다는 건, 클라이언트가 서명할 때 쓴 서비스 이름과 서버가 검증할 때 쓰는 서비스 이름이 &lt;strong&gt;항상 같은 출처&lt;/strong&gt;라는 뜻이다. 둘 다 헤더에서 온다.&lt;/p&gt;
&lt;p&gt;그래서 이런 일이 벌어진다. 어떤 클라이언트가 서비스를 &lt;code&gt;iam&lt;/code&gt;으로 서명해 요청을 만든다. 이 요청을 STS 핸들러에 던진다. STS가 &lt;code&gt;Verify&lt;/code&gt;를 부르면, 헤더에 적힌 &lt;code&gt;iam&lt;/code&gt;으로 키를 파생해 서명을 다시 계산한다. 클라이언트도 &lt;code&gt;iam&lt;/code&gt;으로 서명했으니 — &lt;strong&gt;당연히 맞는다.&lt;/strong&gt; 서명은 자기가 어느 서비스로 갔어야 하는지 모른다. 그저 헤더가 시키는 대로 자기 자신과 아귀만 맞을 뿐이다.&lt;/p&gt;
&lt;p&gt;즉 &lt;code&gt;Verify&lt;/code&gt; 하나만으로는, iam으로 서명한 요청이 STS 문으로 들어와도 통과해 버린다. 서명 검증은 &amp;ldquo;이 서명이 이 헤더 내용과 일치하는가&amp;quot;만 볼 뿐, &amp;ldquo;이 요청이 이 서비스에 와도 되는가&amp;quot;는 보지 않는다.&lt;/p&gt;
&lt;p&gt;막는 방법은 단순하다. 헤더가 주장하는 서비스가, 지금 이 요청을 받은 서비스와 실제로 같은지 명시적으로 확인하면 된다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Service&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;!=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;serviceName&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nx"&gt;AuthError&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;StatusForbidden&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;SignatureDoesNotMatch&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;fmt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;Credential should be scoped to correct service: &amp;#39;%s&amp;#39;.&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;serviceName&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;&lt;code&gt;serviceName&lt;/code&gt;은 클라이언트가 아니라 &lt;strong&gt;핸들러 자신이 아는 값&lt;/strong&gt;이다. STS 핸들러는 &lt;code&gt;&amp;quot;sts&amp;quot;&lt;/code&gt;를 넘겼으니, 헤더의 &lt;code&gt;auth.Service&lt;/code&gt;가 &lt;code&gt;iam&lt;/code&gt;이면 여기서 걸린다. 이게 진짜 AWS가 서비스를 엔드포인트로 갈라 두는 것의 서버 쪽 대응이다.&lt;/p&gt;
&lt;p&gt;실제로 확인해 보자. &lt;code&gt;aws iam&lt;/code&gt; 명령은 서비스 &lt;code&gt;iam&lt;/code&gt;으로 서명하는데, 이걸 STS 포트(:19900)로 보내면:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;$ aws iam create-access-key --user-name friend01 \
 --endpoint-url http://localhost:19900

An error occurred (SignatureDoesNotMatch) when calling the
CreateAccessKey operation: Credential should be scoped to correct
service: &amp;#39;sts&amp;#39;.
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;반대로 &lt;code&gt;aws sts&lt;/code&gt; 명령을 IAM 포트(:19910)로 보내면:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;$ aws sts get-caller-identity --endpoint-url http://localhost:19910

An error occurred (SignatureDoesNotMatch) when calling the
GetCallerIdentity operation: Credential should be scoped to correct
service: &amp;#39;iam&amp;#39;.
&lt;/code&gt;&lt;/pre&gt;&lt;!-- screenshot: 두 개의 크로스-포트 요청이 각각 'correct service' 메시지로 거부되는 터미널 --&gt;
&lt;p&gt;서명은 멀쩡한데 서비스 범위가 안 맞아 거부된다. 이 한 줄짜리 체크가 없었다면, 두 서비스를 나눈 게 이름뿐이고 문은 사실상 하나였을 것이다.&lt;/p&gt;
&lt;h2 id="키의-일생-createaccesskey"&gt;키의 일생: CreateAccessKey
&lt;/h2&gt;&lt;p&gt;이제 IAM이 실제로 하는 일, 액세스키 발급을 보자. &lt;code&gt;CreateAccessKey&lt;/code&gt;는 &lt;code&gt;UserName&lt;/code&gt;을 받는데, 생략하면 &lt;strong&gt;호출자 본인&lt;/strong&gt;에게 키를 발급한다. 실제 AWS와 같은 동작이다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;span class="lnt"&gt;12
&lt;/span&gt;&lt;span class="lnt"&gt;13
&lt;/span&gt;&lt;span class="lnt"&gt;14
&lt;/span&gt;&lt;span class="lnt"&gt;15
&lt;/span&gt;&lt;span class="lnt"&gt;16
&lt;/span&gt;&lt;span class="lnt"&gt;17
&lt;/span&gt;&lt;span class="lnt"&gt;18
&lt;/span&gt;&lt;span class="lnt"&gt;19
&lt;/span&gt;&lt;span class="lnt"&gt;20
&lt;/span&gt;&lt;span class="lnt"&gt;21
&lt;/span&gt;&lt;span class="lnt"&gt;22
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;func&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;createAccessKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ResponseWriter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;credstore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Store&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;caller&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;credstore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Credential&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;userName&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;identity&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;caller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Identity&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;userNameFromARN&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;identity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ARN&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;userName&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;!=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;&amp;#34;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;userName&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;!=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;userName&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;identity&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;credstore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Identity&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Account&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;caller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Identity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Account&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;UserID&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;AIDA&amp;#34;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;mustRandomAlphanumeric&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;accessKeyIDLength&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;ARN&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;arn:aws:iam::&amp;#34;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;caller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Identity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Account&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;:user/&amp;#34;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;userName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;accessKeyID&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;AKIA&amp;#34;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;mustRandomAlphanumeric&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;accessKeyIDLength&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;secretAccessKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;randomSecret&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;accessKeyID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;credstore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Credential&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;SecretAccessKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;secretAccessKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Identity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;identity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// ... AccessKeyId, SecretAccessKey 를 XML 로 응답&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;발급 규칙은 진짜 AWS의 겉모습을 따랐다. 액세스키 ID는 &lt;code&gt;AKIA&lt;/code&gt; 접두사에 대문자·숫자 16글자를 붙여 만들고, 시크릿은 40글자짜리 base64다. 그리고 &lt;strong&gt;발급 즉시 &lt;code&gt;store.Put&lt;/code&gt;으로 저장&lt;/strong&gt;한다. 여기가 핵심이다 — 방금 만든 키가 credstore에 들어감으로써, 곧바로 검증 가능한 자격증명이 된다.&lt;/p&gt;
&lt;p&gt;그 &amp;ldquo;곧바로&amp;quot;를 눈으로 보자. 기본 개발 자격증명(&lt;code&gt;jeff&lt;/code&gt;)으로 &lt;code&gt;friend01&lt;/code&gt;의 키를 발급받는다. IAM 포트로 간다.&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;$ aws iam create-access-key --user-name friend01 \
 --endpoint-url http://localhost:19910
{
 &amp;#34;AccessKey&amp;#34;: {
 &amp;#34;UserName&amp;#34;: &amp;#34;friend01&amp;#34;,
 &amp;#34;AccessKeyId&amp;#34;: &amp;#34;AKIASUCX2E4BPLARBYEZ&amp;#34;,
 &amp;#34;Status&amp;#34;: &amp;#34;Active&amp;#34;,
 &amp;#34;SecretAccessKey&amp;#34;: &amp;#34;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&amp;#34;,
 &amp;#34;CreateDate&amp;#34;: &amp;#34;2026-07-05T13:54:19+00:00&amp;#34;
 }
}
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;(여기 나온 키·시크릿은 로컬 테스트로 방금 생성된 가짜 값이다. 어디에도 통하지 않는다.)&lt;/p&gt;
&lt;p&gt;이제 &lt;strong&gt;방금 받은 그 키&lt;/strong&gt;를 들고, 이번엔 STS 포트로 가서 &amp;ldquo;나는 누구냐&amp;quot;고 묻는다.&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;$ AWS_ACCESS_KEY_ID=AKIASUCX2E4BPLARBYEZ \
 AWS_SECRET_ACCESS_KEY=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 \
 aws sts get-caller-identity --endpoint-url http://localhost:19900
{
 &amp;#34;UserId&amp;#34;: &amp;#34;AIDACNDV6C5Z2A561BOG&amp;#34;,
 &amp;#34;Account&amp;#34;: &amp;#34;123456789012&amp;#34;,
 &amp;#34;Arn&amp;#34;: &amp;#34;arn:aws:iam::123456789012:user/friend01&amp;#34;
}
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;키의 일생이 두 엔드포인트를 넘나든다. IAM(:19910)에서 &lt;strong&gt;발급&lt;/strong&gt;돼 credstore에 &lt;strong&gt;저장&lt;/strong&gt;되고, STS(:19900)에서 &lt;strong&gt;검증&lt;/strong&gt;돼 friend01이라는 신원으로 되돌아온다. 서버 로그가 그 왕복을 그대로 찍는다.&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;iam CreateAccessKey by arn:aws:iam::123456789012:user/jeff
sts GetCallerIdentity by arn:aws:iam::123456789012:user/friend01
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;jeff가 friend01의 키를 발급했고, 그 키가 STS에서 friend01로 인증됐다. 두 서비스가 하나의 저장소를 매개로 대화한 것이다.&lt;/p&gt;
&lt;h2 id="지금은-글로벌이-공짜다"&gt;지금은 &amp;ldquo;글로벌&amp;quot;이 공짜다
&lt;/h2&gt;&lt;p&gt;진짜 AWS에서 IAM이 글로벌이라는 건, 서울에서 만든 사용자가 버지니아에서도 즉시 보인다는 뜻이고, 그 뒤에는 리전 간 복제라는 만만찮은 인프라가 있다. mincloud는 그 문제를 아직 우아하게 &lt;strong&gt;회피&lt;/strong&gt;하고 있을 뿐이다. 프로세스가 하나고 credstore가 인메모리 맵 하나니, IAM이 &lt;code&gt;Put&lt;/code&gt;한 키를 STS가 즉시 &lt;code&gt;Lookup&lt;/code&gt;하는 게 당연하다. 복제도, 지연도, 불일치도 없다 — 나눌 데이터가 애초에 한 벌뿐이라서.&lt;/p&gt;
&lt;p&gt;이 공짜는 인스턴스를 여러 개로 늘리는 순간 끝난다. IAM 인스턴스 A가 발급한 키를, STS 인스턴스 B가 자기 메모리에서 못 찾는 상황이 생긴다. 그때부터 credstore를 프로세스 밖으로 빼고(공유 저장소), 복제와 eventual consistency를 마주해야 한다. &amp;ldquo;방금 만든 키가 잠깐 인식이 안 되는&amp;rdquo; 그 지연이, 사실 진짜 AWS에서도 IAM 변경이 전파되는 데 몇 초씩 걸리는 이유다. 다음 편의 주제로 남겨 둔다.&lt;/p&gt;
&lt;p&gt;정직하게 덧붙이면, 지금 IAM은 &lt;code&gt;CreateAccessKey&lt;/code&gt; 하나뿐이다. &lt;code&gt;CreateUser&lt;/code&gt;도, 사용자 디렉토리도 없다. &lt;code&gt;friend01&lt;/code&gt;은 키를 발급하는 순간 즉석에서 만들어진 신원이지, 어딘가 등록된 사용자가 아니다. &amp;ldquo;사용자를 먼저 만들고, 그 사용자에게 키를 발급한다&amp;quot;는 IAM 본연의 순서는 아직 반쪽이다. 컨트롤플레인이라고 부르기엔 이르지만, 적어도 발급과 검증이 서로 다른 문을 통해 이뤄지는 골격은 이제 섰다.&lt;/p&gt;</description></item><item><title>SigV4 검증기를 Go로 밑바닥부터</title><link>https://blog.j2eff.me/p/sigv4-%EA%B2%80%EC%A6%9D%EA%B8%B0%EB%A5%BC-go%EB%A1%9C-%EB%B0%91%EB%B0%94%EB%8B%A5%EB%B6%80%ED%84%B0/</link><pubDate>Sun, 05 Jul 2026 00:00:00 +0000</pubDate><guid>https://blog.j2eff.me/p/sigv4-%EA%B2%80%EC%A6%9D%EA%B8%B0%EB%A5%BC-go%EB%A1%9C-%EB%B0%91%EB%B0%94%EB%8B%A5%EB%B6%80%ED%84%B0/</guid><description>&lt;!--
제목 후보 (Jeff 선택용):
 1. SigV4 검증기를 Go로 밑바닥부터 ← 현재 가안
 2. 시크릿은 보내지 않는다 — 서버가 같은 계산을 재현한다
 3. 내 검증기가 맞는지는 누가 검증하나 — AWS CLI를 테스트 벡터로
--&gt;
&lt;p&gt;1편에서 &lt;code&gt;aws sts get-caller-identity&lt;/code&gt; 한 줄이 보내는 요청을 통째로 캡처했다. 본문에는 &lt;code&gt;Action=GetCallerIdentity&lt;/code&gt;가 폼으로 실려 있었고, &lt;code&gt;Authorization&lt;/code&gt; 헤더에는 액세스키 ID·서명 범위·서명값이 들어 있었다. 그리고 한 가지를 확인했다 — &lt;strong&gt;시크릿 키는 그 어디에도 없다.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;그럼 시크릿을 받지도 않는데 서버는 어떻게 &amp;ldquo;이 요청을 보낸 사람이 시크릿을 아는 본인&amp;quot;이라고 판정하나. 이번 편은 그 검증을 실제로 짠다. 캡처한 서명이 진짜 맞는 서명인지, 서버가 &lt;strong&gt;똑같은 계산을 재현해서&lt;/strong&gt; 확인하는 과정이다.&lt;/p&gt;
&lt;h2 id="서버가-하는-일은-따라-계산하기다"&gt;서버가 하는 일은 &amp;ldquo;따라 계산하기&amp;quot;다
&lt;/h2&gt;&lt;p&gt;SigV4의 아이디어는 1편에서 정리한 그대로다. 클라이언트는 시크릿으로 요청 내용을 버무려 &lt;strong&gt;서명&lt;/strong&gt;이라는 결과 하나를 만들어 보낸다. 서버는 액세스키 ID로 자기 저장소에서 같은 시크릿을 찾아, 클라이언트가 한 것과 똑같은 계산을 다시 해본다. 두 결과가 같으면 통과다.&lt;/p&gt;
&lt;p&gt;여기서 중요한 건 &lt;strong&gt;방향이 한쪽뿐&lt;/strong&gt;이라는 점이다. 서버는 서명을 &amp;ldquo;풀어보는&amp;rdquo; 게 아니다. 서명은 해시(HMAC-SHA256)의 결과라 되돌릴 수 없다. 서버가 할 수 있는 건 오직 같은 재료로 같은 계산을 반복해서, 나온 값이 클라이언트가 보낸 값과 일치하는지 보는 것뿐이다. 그래서 이 계산을 &lt;strong&gt;바이트 하나 틀리지 않게&lt;/strong&gt; 재현하는 게 검증의 전부다. 재료도, 순서도, 공백 하나까지 클라이언트와 똑같아야 한다.&lt;/p&gt;
&lt;p&gt;계산은 크게 세 단계다. (1) 요청을 정해진 규칙으로 한 덩어리 문자열로 정규화하고(canonical request), (2) 거기에 알고리즘·시각·범위를 붙여 &amp;ldquo;서명할 문자열&amp;quot;을 만들고(string to sign), (3) 시크릿에서 파생한 키로 그 문자열을 HMAC한다. 하나씩 본다.&lt;/p&gt;
&lt;h2 id="1단계--canonical-request"&gt;1단계 — Canonical Request
&lt;/h2&gt;&lt;p&gt;같은 요청이라도 헤더 순서나 대소문자, 공백이 조금씩 다를 수 있다. 그대로 서명하면 클라이언트와 서버가 미세하게 다른 문자열을 서명해 절대 일치하지 않는다. 그래서 SigV4는 요청을 &lt;strong&gt;정규 형식(canonical form)&lt;/strong&gt; 하나로 눌러 담는다. 메서드, 경로, 쿼리, 서명 대상 헤더들, 헤더 목록, 그리고 본문 해시를 정해진 순서로 줄바꿈해 이어 붙인다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;span class="lnt"&gt;12
&lt;/span&gt;&lt;span class="lnt"&gt;13
&lt;/span&gt;&lt;span class="lnt"&gt;14
&lt;/span&gt;&lt;span class="lnt"&gt;15
&lt;/span&gt;&lt;span class="lnt"&gt;16
&lt;/span&gt;&lt;span class="lnt"&gt;17
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;func&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;canonicalRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;signedHeaders&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;payloadHash&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="kd"&gt;var&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;strings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Builder&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WriteString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Method&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// POST&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WriteByte&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sc"&gt;&amp;#39;\n&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WriteString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;escapedPathOr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;/&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WriteByte&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sc"&gt;&amp;#39;\n&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WriteString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;canonicalQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// 키로 정렬, 공백은 %20&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WriteByte&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sc"&gt;&amp;#39;\n&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;range&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;signedHeaders&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;		&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WriteString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;:&amp;#34;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;canonicalHeaderValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;\n&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WriteByte&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sc"&gt;&amp;#39;\n&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WriteString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;strings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;signedHeaders&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WriteByte&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sc"&gt;&amp;#39;\n&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WriteString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payloadHash&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;String&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;디테일이 곧 함정이다. 헤더 값은 앞뒤 공백을 자르고 내부 연속 공백을 하나로 줄인다. 쿼리스트링은 키로 정렬하고 공백을 &lt;code&gt;+&lt;/code&gt;가 아니라 &lt;code&gt;%20&lt;/code&gt;으로 인코딩한다. &lt;code&gt;host&lt;/code&gt; 헤더는 &lt;code&gt;r.Header&lt;/code&gt;가 아니라 &lt;code&gt;r.Host&lt;/code&gt;에서 꺼내야 한다(Go가 Host를 헤더 맵에 넣지 않기 때문이다). 이 중 하나만 어긋나도 canonical request가 달라지고, 최종 서명이 통째로 달라진다. SigV4가 &amp;ldquo;까다롭다&amp;quot;고 느껴지는 이유의 대부분이 이 1단계에 몰려 있다.&lt;/p&gt;
&lt;p&gt;맨 마지막 줄 &lt;code&gt;payloadHash&lt;/code&gt;가 &lt;strong&gt;본문 전체의 SHA-256&lt;/strong&gt;이라는 점을 기억해 두자. 본문이 canonical request 안에 해시로 박혀 들어간다. 뒤에서 다시 쓴다.&lt;/p&gt;
&lt;h2 id="2단계--string-to-sign"&gt;2단계 — String to Sign
&lt;/h2&gt;&lt;p&gt;canonical request를 통째로 한 번 더 해시해서, 알고리즘 이름·요청 시각·서명 범위와 함께 네 줄짜리 문자열로 묶는다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;toSign&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;strings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Join&lt;/span&gt;&lt;span class="p"&gt;([]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="nx"&gt;Algorithm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// &amp;#34;AWS4-HMAC-SHA256&amp;#34;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Header&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;X-Amz-Date&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// &amp;#34;20260705T112547Z&amp;#34;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Scope&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// &amp;#34;20260705/ap-northeast-2/sts/aws4_request&amp;#34;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="nf"&gt;sha256Hex&lt;/span&gt;&lt;span class="p"&gt;([]&lt;/span&gt;&lt;span class="nb"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;canonical&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// 위에서 만든 canonical request의 해시&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;\n&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;여기서 &lt;code&gt;X-Amz-Date&lt;/code&gt;가 서명 문자열 안에 들어간다. 1편에서 &amp;ldquo;이 시각에 서명이 묶인다&amp;quot;고 했던 게 이 줄이다. 요청 시각이 바뀌면 string to sign이 바뀌고 서명이 깨진다. 그래서 어제 캡처한 요청을 오늘 그대로 재전송해도 통하지 않는다.&lt;/p&gt;
&lt;h2 id="3단계--서명-키-파생-체인"&gt;3단계 — 서명 키 파생 체인
&lt;/h2&gt;&lt;p&gt;이제 이 문자열을 HMAC할 키가 필요하다. 그런데 시크릿을 곧바로 키로 쓰지 않는다. 시크릿에서 &lt;strong&gt;네 번의 HMAC을 거쳐&lt;/strong&gt; 파생한 키를 쓴다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;func&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;signingKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;region&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;service&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;hmacSHA256&lt;/span&gt;&lt;span class="p"&gt;([]&lt;/span&gt;&lt;span class="nb"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;AWS4&amp;#34;&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="nb"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// kDate&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;hmacSHA256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="nb"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;region&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// kRegion&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;hmacSHA256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="nb"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;service&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// kService&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;hmacSHA256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="nb"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;aws4_request&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// kSigning&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;시크릿 → kDate → kRegion → kService → kSigning. 왜 이렇게 하나. 파생된 키가 &lt;strong&gt;날짜·리전·서비스에 종속&lt;/strong&gt;되기 때문이다. 오늘 &lt;code&gt;ap-northeast-2&lt;/code&gt;의 &lt;code&gt;sts&lt;/code&gt;용으로 파생한 kSigning은 딱 그 조합에서만 유효하다. 만에 하나 이 파생 키 하나가 새어 나가도, 다른 날짜나 다른 서비스에는 쓸 수 없다. 원본 시크릿을 매번 직접 쓰는 대신, 좁은 범위로 한정된 일회용에 가까운 키를 만들어 쓰는 것이다. 1편에서 본 &lt;code&gt;Credential&lt;/code&gt;의 범위(&lt;code&gt;20260705/ap-northeast-2/sts/aws4_request&lt;/code&gt;)가 바로 이 파생 체인의 입력이었다.&lt;/p&gt;
&lt;p&gt;마지막으로 이 kSigning으로 string to sign을 HMAC하면 64자리 16진수 서명이 나온다. 이게 &lt;code&gt;Authorization&lt;/code&gt; 헤더의 &lt;code&gt;Signature=&lt;/code&gt;와 같아야 한다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;signingKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Region&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Service&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;hex&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;EncodeToString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;hmacSHA256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="nb"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;toSign&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;h2 id="비교는-상수시간으로"&gt;비교는 상수시간으로
&lt;/h2&gt;&lt;p&gt;계산이 끝나면 마지막은 비교다. 그런데 그냥 &lt;code&gt;==&lt;/code&gt;로 문자열을 비교하지 않는다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;hmac&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;want&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;got&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;ErrSignatureMismatch&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;&lt;code&gt;hmac.Equal&lt;/code&gt;은 두 바이트열을 &lt;strong&gt;상수시간(constant-time)&lt;/strong&gt; 으로 비교한다. 보통의 문자열 비교는 첫 글자가 다르면 즉시 멈춘다. 그러면 &amp;ldquo;몇 번째 글자에서 틀렸나&amp;quot;가 걸린 시간으로 새어 나가고, 공격자가 그 시간차를 재서 서명을 한 글자씩 맞춰 갈 여지가 생긴다(타이밍 공격). 상수시간 비교는 일치하든 아니든 항상 끝까지 훑어서 그 정보를 흘리지 않는다. 서명 검증처럼 비밀에 관계된 비교에서는 습관적으로 이걸 쓴다.&lt;/p&gt;
&lt;h2 id="그런데-내-검증기가-맞는지는-누가-검증하나"&gt;그런데, 내 검증기가 맞는지는 누가 검증하나
&lt;/h2&gt;&lt;p&gt;여기까지 짜면 테스트를 붙이고 싶어진다. 자연스러운 방법은 이렇다 — 테스트 안에서 시크릿으로 서명을 하나 만들고(내 서명기), 그걸 내 검증기에 넣어 통과하는지 본다.&lt;/p&gt;
&lt;p&gt;이 테스트는 &lt;strong&gt;항상 통과한다.&lt;/strong&gt; 그리고 바로 그게 함정이다.&lt;/p&gt;
&lt;p&gt;내 서명기와 내 검증기가 같은 실수를 공유하면, 예컨대 둘 다 헤더 정렬을 빼먹었다면, 둘은 사이좋게 틀린 채로 서로를 인정한다. 테스트는 초록불인데 진짜 AWS CLI가 보낸 요청은 죄다 거부하는 검증기가 만들어진다. 자기가 만든 답으로 자기 채점을 하니 틀려도 알 길이 없다.&lt;/p&gt;
&lt;p&gt;그래서 테스트 벡터를 &lt;strong&gt;바깥에서&lt;/strong&gt; 가져왔다. 1편에서 캡처한, 진짜 AWS CLI v2가 만든 서명 두 개다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;span class="lnt"&gt;7
&lt;/span&gt;&lt;span class="lnt"&gt;8
&lt;/span&gt;&lt;span class="lnt"&gt;9
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// 실제 aws CLI v2 요청에서 캡처 (가짜 테스트 자격증명).&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// AWS_ACCESS_KEY_ID=MINCLOUDTESTKEY0000A&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// AWS_SECRET_ACCESS_KEY=mincloud-test-secret-not-real&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;var&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;capturedRequests&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kd"&gt;struct&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;amzDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;signature&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;first capture&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;20260705T112547Z&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;285b60204ba0de2785782461e04d6aa0c962ec010b8196276f4a1112306dab85&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;second capture&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;20260705T112814Z&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;3e4361b978419419ae9c741d1c518fcf2930d8afdcaed1256962637320bb3984&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;(여기 키·시크릿은 전부 가짜 테스트 값이다. &lt;code&gt;MINCLOUDTESTKEY0000A&lt;/code&gt;, &lt;code&gt;mincloud-test-secret-not-real&lt;/code&gt; — 실제 AWS에서는 아무 데도 통하지 않는다.)&lt;/p&gt;
&lt;p&gt;이제 테스트의 의미가 완전히 달라진다. 내 &lt;code&gt;ComputeSignature&lt;/code&gt;가 뱉은 값이 이 문자열과 같다는 건, &lt;strong&gt;내 구현이 AWS의 SigV4 구현과 바이트 단위로 일치한다&lt;/strong&gt;는 뜻이다. 서명 두 개는 시각(&lt;code&gt;X-Amz-Date&lt;/code&gt;)만 다르다. 같은 재료로 시각만 바꿨을 때 두 서명이 다르게, 그러나 각각 정확히 나온다면 시각이 계산에 제대로 반영됐다는 것까지 한 번에 검증된다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;got&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;ComputeSignature&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="nb"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;testBody&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;testSecretKey&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;got&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;!=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;tc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signature&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;ComputeSignature = %s, want %s&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;got&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;tc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;이 한 줄이 초록불이면, 우리 검증기는 더 이상 &amp;ldquo;내가 나를 믿는&amp;rdquo; 검증기가 아니다. AWS가 기준점이다.&lt;/p&gt;
&lt;h2 id="본문을-건드리면-서명이-깨진다"&gt;본문을 건드리면 서명이 깨진다
&lt;/h2&gt;&lt;p&gt;1단계에서 본문 전체의 해시가 canonical request 맨 끝에 박힌다고 했다. 그 효과를 테스트로 못박아 둔다. 캡처한 서명은 그대로 두고, 본문만 &lt;code&gt;GetCallerIdentity&lt;/code&gt;에서 &lt;code&gt;AssumeRole&lt;/code&gt;로 바꿔 검증해 본다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;tampered&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="nb"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;Action=AssumeRole&amp;amp;Version=2011-06-15&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;Verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;tampered&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;testSecretKey&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;!=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;ErrSignatureMismatch&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;tampered body should fail&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;결과는 &lt;code&gt;ErrSignatureMismatch&lt;/code&gt;. 본문이 서명 안에 해시로 묶여 있으니, 중간에서 누가 &lt;code&gt;GetCallerIdentity&lt;/code&gt;를 &lt;code&gt;AssumeRole&lt;/code&gt;로 바꿔치기하면 서명이 곧바로 깨진다. 서명은 &amp;ldquo;누가 보냈나&amp;quot;만이 아니라 &lt;strong&gt;&amp;ldquo;내용이 그대로인가&amp;rdquo;&lt;/strong&gt; 까지 함께 보증한다. 마찬가지로 잘못된 시크릿(&lt;code&gt;wrong-secret&lt;/code&gt;)으로 검증하면 파생 키가 달라져 역시 &lt;code&gt;ErrSignatureMismatch&lt;/code&gt;가 난다.&lt;/p&gt;
&lt;h2 id="부록--openssl로-손-서명"&gt;부록 — openssl로 손 서명
&lt;/h2&gt;&lt;p&gt;이 계산에 마법은 없다. &lt;code&gt;docs/sign-by-hand.sh&lt;/code&gt;는 전체 과정을 셸 스크립트로, &lt;code&gt;openssl&lt;/code&gt;과 &lt;code&gt;shasum&lt;/code&gt;만으로 재현한다. 본문 해시 → canonical request → string to sign → 파생 체인(&lt;code&gt;kDate&lt;/code&gt;→&lt;code&gt;kRegion&lt;/code&gt;→&lt;code&gt;kService&lt;/code&gt;→&lt;code&gt;kSigning&lt;/code&gt;) → 최종 서명을 한 줄씩 만들어 &lt;code&gt;curl&lt;/code&gt;에 실어 보낸다. Go 코드가 하는 일과 한 단계씩 정확히 대응한다. SigV4를 손으로 한 번 따라 서명해 보면 &amp;ldquo;왜 이 순서인가&amp;quot;가 몸에 남는다.&lt;/p&gt;
&lt;h2 id="다음-편"&gt;다음 편
&lt;/h2&gt;&lt;p&gt;서명 검증은 이제 실제 AWS와 바이트 단위로 맞는다. 하지만 서명이 맞는 것과, CLI가 만족하는 응답을 돌려주는 것은 다른 문제다. 서버는 아직 액세스키 ID로 시크릿을 어떻게 찾는지(credstore), 신원을 CLI가 알아듣는 XML로 어떻게 돌려주는지(STS &lt;code&gt;GetCallerIdentity&lt;/code&gt;), 그리고 서명이 틀렸을 때 진짜 AWS와 &lt;strong&gt;똑같은 에러 코드&lt;/strong&gt;로 어떻게 거절하는지를 정하지 않았다.&lt;/p&gt;
&lt;p&gt;다음 편에서 그 세 조각을 붙인다. 그리고 &lt;code&gt;aws sts get-caller-identity&lt;/code&gt;가 우리 XML을 아무 불평 없이 파싱해 신원을 그대로 리턴하는 순간 — CLI가 우리를 진짜 AWS로 착각하는 순간 — 을 본다.&lt;/p&gt;</description></item><item><title>Packer로 Ubuntu 22.04 QCOW2 베이스 이미지 만들기</title><link>https://blog.j2eff.me/p/packer%EB%A1%9C-ubuntu-22.04-qcow2-%EB%B2%A0%EC%9D%B4%EC%8A%A4-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EB%A7%8C%EB%93%A4%EA%B8%B0/</link><pubDate>Sun, 17 Aug 2025 00:00:00 +0000</pubDate><guid>https://blog.j2eff.me/p/packer%EB%A1%9C-ubuntu-22.04-qcow2-%EB%B2%A0%EC%9D%B4%EC%8A%A4-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EB%A7%8C%EB%93%A4%EA%B8%B0/</guid><description>&lt;p&gt;로컬에 데이터센터를 만들려면 맨 아래층부터 시작해야 한다. 그 시작이 VM이다. 그런데 우분투를 매번 ISO로 깔면 유저 만들고, 디스크 나누고, 패키지 설치하는 데만 20–30분이 든다. 실습 환경을 열 번 갈아엎으면 그 시간도 열 배가 된다.&lt;/p&gt;
&lt;p&gt;이 글에서는 &lt;a class="link" href="https://github.com/j2eff-we/lab-infra" target="_blank" rel="noopener"
 &gt;lab-infra&lt;/a&gt; 프로젝트로 로컬 KVM 기반 VM 실습 환경을 자동으로 구성한다. Packer로 Ubuntu 22.04 기반 이미지를 한 번 빌드해두고, 이후 libvirt + Terraform으로 몇 초 만에 VM을 찍어내는 것이 목표다. 이 편은 그 첫 단계 — &lt;strong&gt;베이스 이미지 만들기&lt;/strong&gt;다.&lt;/p&gt;
&lt;h2 id="시리즈-로드맵"&gt;시리즈 로드맵
&lt;/h2&gt;&lt;ul&gt;
&lt;li&gt;0편: Packer로 base 이미지 생성 (이 글)&lt;/li&gt;
&lt;li&gt;1편: Terraform으로 libvirt provider 설정&lt;/li&gt;
&lt;li&gt;2편: VM 프로비저닝 및 네트워킹 구성&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="왜-packer인가"&gt;왜 Packer인가
&lt;/h2&gt;&lt;p&gt;Packer는 HashiCorp에서 만든 이미지 빌드 도구다. 하나의 구성으로 여러 플랫폼에 동일한 이미지를 자동 생성할 수 있다. 우분투 ISO로 매번 수동 설치하는 대신 &lt;strong&gt;커스텀 이미지&lt;/strong&gt;를 만들어두면, 설치 시간이 줄고 환경이 통일된다. CI/CD·테스트·실험용 VM처럼 같은 환경을 반복해서 띄우는 경우에 특히 유리하다.&lt;/p&gt;
&lt;p&gt;실제로 이런 상황에서 값어치를 한다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;여러 명이 동일한 실습 환경을 써야 할 때&lt;/li&gt;
&lt;li&gt;서버를 부팅하자마자 곧바로 서비스 테스트가 가능해야 할 때&lt;/li&gt;
&lt;li&gt;CI 파이프라인에서 반복적으로 VM을 생성하는 경우&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="qcow2는-무엇이고-왜-쓰는가"&gt;qcow2는 무엇이고 왜 쓰는가
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;qcow2&lt;/code&gt;는 QEMU/KVM에서 쓰는 가상 디스크 포맷이다. 세 가지 이점이 있다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;스냅샷을 지원한다.&lt;/li&gt;
&lt;li&gt;sparse·압축으로 디스크 공간을 절약한다.&lt;/li&gt;
&lt;li&gt;VM 간 공유와 배포가 쉽다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="커스텀-이미지를-만드는-이유"&gt;커스텀 이미지를 만드는 이유
&lt;/h2&gt;&lt;p&gt;기본 Ubuntu ISO로 설치하면 부팅 후 유저 생성, 디스크 파티션 설정, 패키지 설치 같은 수동 과정을 거쳐 20–30분이 걸린다. Packer에 cloud-init을 붙이면 유저·hostname·ssh key·커널 설치까지 자동화된 qcow2 이미지를 만들 수 있다. 그러면 같은 환경에서 수초–수분 내에 VM이 뜬다. 초기화 시간이 줄고 반복 작업이 사라지므로, 테스트 자동화(CI/CD, GitHub Actions 등)에도 그대로 이어진다.&lt;/p&gt;
&lt;h2 id="사전-준비"&gt;사전 준비
&lt;/h2&gt;&lt;ul&gt;
&lt;li&gt;Ubuntu 기반 호스트 (예: Ubuntu 22.04 Desktop)&lt;/li&gt;
&lt;li&gt;가상화 지원 CPU (BIOS에서 VT-x/AMD-V 활성화)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;git&lt;/code&gt;, &lt;code&gt;make&lt;/code&gt;, &lt;code&gt;libvirt&lt;/code&gt;, &lt;code&gt;qemu&lt;/code&gt;, &lt;code&gt;packer&lt;/code&gt;, &lt;code&gt;tfenv&lt;/code&gt; 등은 아래 과정에서 자동으로 설치된다. Terraform 버전은 &lt;code&gt;.terraform-version&lt;/code&gt; 파일을 통해 자동으로 선택된다.&lt;/p&gt;
&lt;h2 id="1단계-초기-설정"&gt;1단계: 초기 설정
&lt;/h2&gt;&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sudo apt update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; sudo apt install -y build-essential curl git sudo
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# lab-infra 프로젝트 클론&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;git clone https://github.com/j2eff-we/lab-infra.git
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; lab-infra/
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;h2 id="2단계-의존성-준비"&gt;2단계: 의존성 준비
&lt;/h2&gt;&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;make setup-sudoers &lt;span class="c1"&gt;# 실행 후 로그아웃 → 재접속&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;make prepare &lt;span class="c1"&gt;# 실행 후 로그아웃 → 재접속 (libvirt 그룹 적용)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;두 명령 모두 그룹 권한이 새로 붙기 때문에, 실행한 다음 로그아웃 후 다시 접속해야 반영된다.&lt;/p&gt;
&lt;h2 id="3단계-qemu--packer-버전-확인"&gt;3단계: QEMU / Packer 버전 확인
&lt;/h2&gt;&lt;p&gt;환경이 제대로 깔렸는지 빠르게 확인한다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;make check
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;예상 출력은 이렇다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Packer version: Packer v1.10.0
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;QEMU version: QEMU emulator version 8.2.1
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;출력이 비어 있거나 버전이 안 나오면, &lt;code&gt;make prepare&lt;/code&gt; 이후 터미널을 재시작하거나 로그아웃 후 다시 로그인해야 한다.&lt;/p&gt;
&lt;h2 id="4단계-packer로-이미지-빌드"&gt;4단계: Packer로 이미지 빌드
&lt;/h2&gt;&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; base/packer
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;packer init ubuntu.pkr.hcl
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;packer build jammy.json
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;Ubuntu ISO는 자동으로 다운로드된다. 첫 빌드는 ISO 다운로드와 설치로 &lt;strong&gt;약 15–30분&lt;/strong&gt;이 걸린다. 빌드 도중 실패하면 &lt;code&gt;[context canceled]&lt;/code&gt; 에러가 날 수 있는데, 다시 시도하면 된다.&lt;/p&gt;
&lt;p&gt;빌드가 끝나면 결과물 &lt;code&gt;.qcow2&lt;/code&gt; 파일을 확인한다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ls -lh base/packer/build-jammy-base/*.qcow2
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;성공하면 이런 메시지가 나온다.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;-rw-r--r-- 1 user user 1.3G Dec 15 10:30 build-jammy-base/ubuntu-jammy-base.qcow2
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;h2 id="템플릿-구조"&gt;템플릿 구조
&lt;/h2&gt;&lt;ul&gt;
&lt;li&gt;&lt;code&gt;ubuntu.pkr.hcl&lt;/code&gt; — 메인 entrypoint. 실제로 쓸 빌더와 변수 파일을 정의한다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;jammy.json&lt;/code&gt; — 빌더, 부트 커맨드, 디스크 포맷, ISO 설정을 담는다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;http/&lt;/code&gt; — cloud-init 설정이 들어 있고, Packer가 빌드 중 로컬 서버로 제공한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="다음-편"&gt;다음 편
&lt;/h2&gt;&lt;p&gt;베이스 이미지가 생겼으니, 다음 편에서는 이 이미지를 Terraform libvirt provider로 VM에 적용하고 실제로 띄워본다.&lt;/p&gt;</description></item></channel></rss>