최초의 계정은 어디서 오는가

Cloud Control Plane
  1. 1. AWS CLI는 무엇을 보내는가
  2. 2. SigV4 검증기를 Go로 밑바닥부터
  3. 3. CLI가 속아 넘어가는 순간
  4. 4. IAM과 STS를 나누다
  5. 5. credstore를 프로세스 밖으로 — DynamoDB에 얹다
  6. 6. 최초의 계정은 어디서 오는가
  7. 7. 맨 아래 거북이 — 제네시스 계정과 신뢰의 뿌리
  8. 8. 인증은 됐는데 권한이 없다 — 서명된 CreateAccount
  9. 9. 루트키를 넘기지 마라 — 역할과 AssumeRole
전체 9편
6 / 9

5편까지 mincloud엔 계정이 딱 하나였다. 부팅할 때 심는 개발용 씨앗 자격증명. 그걸로 STS도 부르고 IAM 키도 발급했다. 그런데 그 씨앗을 안 쓰고 맨손으로 첫 키를 만들어 보려 하면, 벽에 부딪힌다.

$ aws iam create-access-key --user-name friend01 \
    --endpoint-url http://localhost:9910

Unable to locate credentials.

당연하다. create-access-key는 IAM 요청이고, IAM 요청은 SigV4로 서명해야 하고, 서명하려면 이미 자격증명이 있어야 한다. 키를 만들려면 키가 있어야 한다. 그럼 애초에 최초의 하나는 어떻게 존재하는가?

서명할 수 없는 문

답부터 말하면, 최초의 계정은 우리 API로 만들 수 없다. 만들면 안 된다.

CreateAccount 같은 걸 SigV4 API에 넣었다고 하자. 그 요청에 서명하려면 키가 필요하고, 그 키는 계정이 있어야 나오고, 계정은… 순환이다. 그래서 AWS도 가입(sign-up)을 SigV4 API로 만들지 않았다. 가입은 완전히 다른 평면이다.

  • 서비스 평면 — sts, iam, ec2. SigV4로 인증한다. aws CLI가 여기랑 대화한다.
  • 가입/콘솔 평면 — 인증이 아니라 다른 신뢰(이메일 확인·결제)로 계정과 루트를 계정 밖에서 찍어낸다. 여기엔 액세스키가 필요 없다 — 그걸 처음 만드는 곳이니까.

즉 부트스트랩은 서명의 바깥에 있어야 한다. mincloud도 똑같이 둘로 나눈다. 서비스 평면은 그대로 두고, 계정을 찍어내는 일은 서명하지 않는 별도의 문에 둔다. 그 문의 신뢰는 서명이 아니라 “이 배포에 손을 댈 수 있는가"다.

1
2
3
4
5
6
7
8
9
// admin 서브커맨드는 부트스트랩 평면이다. 서명하는 서비스 API가 아니라,
// 최초 자격증명을 '만드는' 쪽. 그래서 SigV4를 거치지 않는다.
func main() {
	if len(os.Args) > 1 && os.Args[1] == "admin" {
		runAdmin(os.Args[2:])
		return
	}
	runServer()
}

지금까지 쓰던 씨앗 자격증명은, 사실 “이미 가입돼 있는 계정 하나를 미리 박아둔 것"이었다. 이제 그 문을 진짜로 연다.

계정을 찍어내다

admin create-account는 새 계정을 만든다. 계정 ID, 루트 유저, 그리고 그 계정만의 루트 액세스키/시크릿을 발급해 credstore(5편의 DynamoDB)에 저장하고, 운영자에게 건넨다.

$ mincloud admin create-account
account created — hand the root credential to the account owner:
  Account:          340106438124
  Root AccessKeyId: AKIAE6L7MMXWPV4XWCNQ
  Root SecretKey:   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

(여기 값은 로컬에서 방금 만든 가짜다.) 이 문은 배포 안에서만 연다. 서명이 없으니 아무 데서나 두드리게 두면 안 되고, “박스에 접근할 수 있는 운영자” 가 신뢰의 경계다. 이게 AWS 콘솔 가입의 mincloud판이다 — 계정 밖 신뢰로 루트를 찍어내는 문.

받은 루트키로 STS에 물으면, 신원이 돌아온다.

$ aws sts get-caller-identity --endpoint-url http://localhost:9900
{
    "UserId": "340106438124",
    "Account": "340106438124",
    "Arn": "arn:aws:iam::340106438124:root"
}

Arn:root로 끝나고, UserId가 계정 ID와 똑같다. 이게 루트의 표식이다. (뒤에서 IAM 유저와 대조한다.)

12자리는 어디서 오나

여기서 멈칫할 수 있다. 저 340106438124라는 12자리는 그냥 랜덤으로 뽑은 숫자인가? 그러면 계정 ID와 액세스키가 아무 관계 없는 두 난수가 된다. 그런데 진짜 AWS는 그렇게 안 한다.

잘 알려진 사실인데(Tal Be’ery, Aidan Steele의 연구), AWS 계정 ID는 액세스키 ID 안에 인코딩돼 있다. AKIA… 키만 있으면 API 호출 없이 오프라인으로 계정을 복원할 수 있다.

1
2
3
4
5
import base64
def account_of(key):
    raw = base64.b32decode(key[4:])          # AKIA 뒤 16글자 → 10바이트
    z = int.from_bytes(raw[0:6], "big")      # 앞 6바이트(48비트)
    return "{:012d}".format((z & 0x7fffffffff80) >> 7)

mincloud도 이 방식을 그대로 따랐다. create-account가 12자리를 정하면, 발급하는 액세스키가 그 계정을 base32 본문에 품도록 만든다. 그래서 우리가 만든 키를 진짜 공개 디코더에 넣어도 우리 계정이 나온다.

$ mincloud admin whoami AKIAE6L7MMXWPV4XWCNQ
AKIAE6L7MMXWPV4XWCNQ -> account 340106438124

$ python3 decode.py AKIAE6L7MMXWPV4XWCNQ
340106438124

계정이 “떠다니는 난수"가 아니라 키가 지니고 다니는 값이 된 것이다. 눈으로도 보인다. 같은 계정에서 발급한 두 키는 앞부분을 공유한다.

AKIAE6L7MMXW PV4XWCNQ   ← 루트
AKIAE6L7MMXW D6RJRYGX   ← 같은 계정의 다른 키
──────────── ────────
계정 인코딩    키별 랜덤

앞 열두 글자는 계정에서 파생돼 똑같고, 뒤는 키마다 다른 랜덤이다.

루트가 유저를 만든다

이제 계정이 여럿이 될 수 있으니, 격리가 진짜 문제가 된다. 방금 만든 루트로 자기 계정 안에 IAM 유저 alice를 발급해 보자.

$ aws iam create-access-key --user-name alice --endpoint-url http://localhost:9910
...AccessKeyId: AKIAE6L7MMXWD6RJRYGX...

$ aws sts get-caller-identity --endpoint-url http://localhost:9900   # alice 키로
{
    "UserId": "AIDAY7WVA4T7QWXELDV5",
    "Account": "340106438124",
    "Arn": "arn:aws:iam::340106438124:user/alice"
}

alice는 이 루트가 속한 계정(340106438124) 안의 유저다. 루트가 만든 키가 다른 계정으로 새지 않는다 — 자격증명마다 자기 계정을 품고 다니기 때문이다. alice의 키를 오프라인 디코드해도 같은 340106438124가 나온다.

그리고 루트와 IAM 유저는 겉모습으로 구별된다.

정체ARN 끝UserId
루트:root계정 ID와 동일
IAM 유저:user/<이름>AIDA… 로 시작

get-caller-identity가 돌려주는 이 한 줄이, “너가 루트냐 유저냐"를 그대로 말해 준다. 이 구분은 뒤에서 인가(authorization)를 다룰 때 핵심 열쇠가 된다 — 루트는 암묵적으로 다 되고, :user/…는 정책이 있어야 뭔가 할 수 있다.

두 평면, 그리고 남은 빚

정리하면, mincloud는 이제 두 개의 문을 가진다. 서명하는 서비스 평면과, 서명하지 않는 부트스트랩 평면. 후자는 서명이 없어야 한다는 게 핵심이다 — 서명을 붙이는 순간 최초의 계정을 만들 길이 다시 막힌다.

이건 은근히 미끄러운 규칙이다. AWS CLI는 커스텀 모델(aws configure add-model)로 새 명령을 붙일 수 있어서, 마음만 먹으면 aws mincloud create-account 같은 걸 만들 수도 있다. 하지만 이 문만은 거기 두면 안 된다. CLI 명령이 되는 순간 SigV4로 서명되고, chicken-and-egg가 부활한다. 부트스트랩은 끝까지 서명 바깥에 있어야 한다.

정직하게 남는 빚도 있다. 씨앗 계정은 여전히 하드코딩된 특별한 존재고, create-account는 지금 누구 손을 거쳐야 하는지(승인·쿼터)를 따지지 않는다. “박스에 접근하면 계정을 무한정 찍는다"는 건 데모니까 넘어가는 것이지, 진짜 가입 플로우는 그 앞에 결제와 검증이 있다.

그런데 여기까지 오는 동안, 클라이언트는 늘 aws CLI가 명령 하나를 던지는 식이었다. 다음 편에서는 훨씬 까다로운 손님을 부른다 — Terraform. plan → apply → destroy를 돌리는 이 손님은, 명령 하나로는 만족하지 않는다. mincloud의 반쪽짜리 IAM이 진짜 CRUD가 되어야 하는 순간이 거기서 온다.