Post

IAM에 kms:* 다 줬는데 AccessDenied가 났다

KMS는 AWS에서 거의 유일하게 리소스 정책이 필수인 서비스다. IAM 상식이 안 통하는 지점과, root 주체의 의미.

IAM에 kms:* 다 줬는데 AccessDenied가 났다

7월 어느 날 노트에 이렇게 적어뒀다.

1
kms, iam deep dive 필요

IAM은 1편과 2편으로 정리했는데 KMS는 계속 미뤘다. 미룬 이유는 단순히, KMS를 자세하게 사용하지 않아서였다. 키 만들고, 서비스에서 그 키 고르고, 끝. 콘솔에서 하면 클릭 몇 번이다.

그러다 몇 번 막혔다. 사내 공용 키 하나를 여러 서비스가 같이 쓰고 있는데 “여기에 뭘 수정해야 하지?”에서 멈췄고, CloudTrail을 KMS 암호화로 깔면서 “이 키는 누가 쓸 수 있게 해야 하지?”에서 또 멈췄다.

막힌 지점이 다 같았다. IAM에서 통하던 상식이 KMS에서는 안 통한다. 그 차이가 전부 키 정책(key policy) 하나에서 나온다.


먼저, KMS는 내 데이터를 암호화하지 않는다

키 정책 얘기를 하기 전에 이것부터 짚어야 한다. 권한 이름이 왜 그렇게 생겼는지가 여기서 나오기 때문이다.

처음엔 이렇게 생각했다. “S3에 1GB 파일을 올리면 KMS가 그걸 암호화해주는 거겠지.”

아니다. KMS는 애초에 큰 데이터를 받지 않는다. Encrypt API에 직접 넣을 수 있는 데이터는 4KB까지다. 1GB 파일은 들어가지도 않는다.

그럼 어떻게 하는거지?

IMG_5602

이때 나오는 봉투 암호화(envelope encryption)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
① GenerateDataKey 호출
   KMS가 데이터 키를 하나 만들어서 두 벌로 준다
      ├─ 평문 데이터 키        (바로 쓸 수 있는 키)
      └─ 암호화된 데이터 키     (KMS 키로 감싼 것)

② 평문 데이터 키로 내 데이터를 암호화한다   ← 실제 암호화는 여기서, KMS 밖에서
③ 평문 데이터 키는 메모리에서 버린다
④ 암호화된 데이터 키는 암호문 옆에 같이 저장한다

   [암호화된 데이터 키] + [암호화된 1GB 데이터]

복호화할 때
⑤ 암호화된 데이터 키를 KMS에 보내 Decrypt → 평문 데이터 키를 돌려받는다
⑥ 그 키로 데이터를 푼다

KMS가 하는 일은 데이터 키를 만들어주고, 나중에 그걸 풀어주는 것뿐이다. 진짜 암호화는 S3나 EBS가 자기 쪽에서 한다.

여기서 실무로 바로 이어지는 게 있다. 그래서 S3에 파일 하나 올리는 데 필요한 KMS 권한이 kms:Encrypt가 아니라 kms:GenerateDataKey다. 권한 목록을 보고 “암호화하는데 왜 Encrypt가 없지?” 했던 게 이것 때문이었다. 반대로 다운로드할 때는 kms:Decrypt가 필요하다. 암호화된 데이터 키를 풀어야 하니까.


키 정책은 필수다

리소스 정책이 있는 서비스는 여럿이다. S3 버킷 정책, SQS 큐 정책, SNS 토픽 정책. 그런데 이것들은 전부 선택이다. 버킷 정책을 안 붙여도 IAM 정책만으로 S3를 쓸 수 있다.

KMS는 다르다.

공식문서 · Key policies in AWS KMS — “Every KMS key must have exactly one key policy.” · “No AWS principal, including the account root user or key creator, has any permissions to a KMS key unless they are explicitly allowed, and never denied, in a key policy, IAM policy, or grant.”

키를 만든 사람조차 기본 권한이 없다. 그리고 더 중요한 문장이 이거다.

· 같은 문서 — “Unless the key policy explicitly allows it, you cannot use IAM policies to allow access to a KMS key. Without permission from the key policy, IAM policies that allow permissions have no effect. (You can use an IAM policy to deny a permission to a KMS key without permission from a key policy.)”

키 정책이 허용하지 않으면 IAM 정책은 아무 효과가 없다. 역할에 kms:*를 Resource: "*"로 박아놔도 안 된다. 내가 겪은 AccessDenied가 정확히 이거였고, IAM만 들여다보고 있으면 절대 안 풀린다.

정리하면 이렇다.

 리소스 정책IAM만으로 접근 가능?
S3 버킷선택가능
SQS 큐선택가능
KMS 키필수 (반드시 1개)불가능 — 키 정책이 열어줘야 함

한 가지 더. IAM 정책은 글로벌인데 키 정책은 리전 단위다.

statement를 한 줄씩 뜯어보면

키 정책 statement는 Effect · Principal · Action · Resource 네 개가 필수다. Sid와 Condition은 선택이다. 문서의 예시가 이렇게 생겼다.

1
2
3
4
5
6
7
8
{
  "Sid": "DescribePolicyStatement",
  "Effect": "Allow",
  "Principal": { "AWS": "arn:aws:iam::111122223333:user/Alice" },
  "Action": "kms:DescribeKey",
  "Resource": "*",
  "Condition": { "StringEquals": { "kms:KeySpec": "SYMMETRIC_DEFAULT" } }
}

한 줄씩 읽으면 이렇게 된다.

요소질문값
Effect허용인가 거부인가Allow / Deny
Principal누가계정, IAM 역할·사용자, 일부 AWS 서비스
Action무엇을kms:Decrypt, kms:GenerateDataKey …
Resource어떤 키에항상 "*" — “이 키”
Condition어떤 조건에서만kms:ViaService, kms:EncryptionContext …

Effect — 명시적으로 Allow가 없으면 묵시적 거부다. KMS는 여기에 더해 키 정책 자체가 허용해야 한다는 조건이 하나 더 붙는다.

Principal — IAM 정책에는 아예 없는 요소다. 신원 정책은 붙어 있는 대상이 곧 주체니까 적을 필요가 없는데, 키 정책은 키에 붙으니 “누구”를 적어야 한다. 그리고 IAM 그룹은 어떤 정책에서도 주체가 될 수 없다. 그룹은 신원이 아니라 정책을 묶어두는 상자라서다. 2편에서 “그룹과의 연결 정보는 요청할 때마다 조립한다”고 정리했던 것과 같은 얘기다.

Action — API 오퍼레이션 이름을 그대로 쓴다. kms:Encrypt는 Encrypt API에 대응한다.

Resource — 여기가 처음에 걸렸다.

1
"Resource": "*"

이게 “모든 키”로 읽혔다. 아니었다.

공식문서 · Creating a key policy — “In a key policy, the value of the Resource element is "*", which means “this KMS key.” The asterisk ("*") identifies the KMS key to which the key policy is attached.”

키 정책은 이미 특정 키에 붙어 있으니 대상을 적을 이유가 없다. 같은 "*"인데 어느 정책에 있느냐로 뜻이 뒤집힌다.

 "Resource": "*" 의 의미
키 정책이 키 하나
IAM 정책계정의 모든 KMS 키

그리고 여기에 조용한 함정이 하나 있다.

· 같은 문서 — “If the required Resource element is missing from a key policy statement, the policy statement has no effect. … When a key policy statement is missing its Resource element, the AWS KMS console correctly reports an error, but the CreateKey and PutKeyPolicy APIs succeed, even though the policy statement is ineffective.”

Action도 똑같다. 즉 CloudFormation이나 CLI로 키 정책을 넣으면서 Resource를 빠뜨리면 배포는 성공한다. 그런데 그 statement는 아무 일도 하지 않는다. 콘솔로 만들었으면 에러로 막혔을 걸, API는 그냥 통과시킨다.

콘솔에서 키 정책을 열어보면 분명 내가 넣은 statement가 있는데 AccessDenied가 나는 경우 — 이쪽을 한 번 의심해볼 만하다.


"AWS": "...:root" 는 루트 계정 얘기가 아니다

키를 만들면 기본 키 정책이 붙는다. 이렇게 생겼다.

1
2
3
4
5
6
7
8
9
{
  "Sid": "Enable IAM User Permissions",
  "Effect": "Allow",
  "Principal": {
    "AWS": "arn:aws:iam::111122223333:root"
  },
  "Action": "kms:*",
  "Resource": "*"
}

처음 이걸 보고 생각한 건 두 가지였다.

  1. “루트 사용자한테 kms:*를 줬네? 위험한 거 아닌가?”
  2. “그럼 내 역할은 여기 없는데 왜 키를 쓸 수 있지?”

둘 다 틀렸다. 저 :root는 루트 사용자가 아니라 계정 주체(account principal)다. 그리고 하는 일이 전혀 다르다.

공식문서 · Default key policy — “When the principal in a key policy statement is the account principal, the policy statement doesn’t give any IAM principal permission to use the KMS key. Instead, it allows the account to use IAM policies to delegate the permissions specified in the policy statement.” · “Unlike other AWS resource policies, an AWS KMS key policy does not automatically give permission to the account or any of its identities.”

이 문장은 권한을 주는 게 아니라 “이 키에 대한 판단을 이 계정의 IAM 정책에 위임한다”는 스위치다.(key는 이미 승인된 상태고 이게 iam에서만 승인이 되면 사용 가능하다) 그래서 Sid가 Enable IAM User Permissions다. 이름 그대로 “IAM을 켠다”는 뜻이었다.

그러니까 저 한 줄이 있어서 IAM 정책이 작동하던 거고, 저걸 지우면 그 계정의 IAM 정책 전부가 이 키에 대해 무력화된다.

1
2
3
4
5
6
기본 키 정책 statement 있음
   역할의 IAM 정책에 kms:Decrypt 있음  →  통과 ✓

기본 키 정책 statement 없음
   역할의 IAM 정책에 kms:* 다 있음     →  AccessDenied ✗
   (키 정책에 그 역할을 직접 써줘야 함)

그래서 문이 두 개다

결국 키 정책 게이트는 건너뛸 수 없다. 그 게이트를 통과하는 방법이 두 가지인 것뿐이다.

kms-gate-light

A. 키 정책이 주체를 직접 지목한 경우 — 이러면 끝이다. 그 역할의 IAM 정책에 kms 권한이 하나도 없어도 키를 쓸 수 있다. 콘솔에서 키를 만들 때 “키 사용자”로 역할을 고르면 이 statement가 붙는다.

B. :root 로 IAM에 위임한 경우 — 이때만 IAM 정책을 본다. 그래서 게이트가 두 개로 느껴지는 것이고, 내가 겪은 AccessDenied도 여기였다.

C. 둘 다 없는 경우 — IAM에 kms:*가 있어도 거부다.

위임에는 범위가 있다. 기본 statement의 Action이 kms:*라 전부 위임되는 거지, 저길 kms:Decrypt로 좁혀두면 IAM에 kms:Encrypt를 줘도 안 된다. IAM은 위임받은 범위 안에서만 허용할 수 있다.

그리고 Deny는 이 구조와 무관하게 성립한다. 위 인용문 괄호에 있던 내용이다 — 키 정책이 아무것도 허용하지 않아도 IAM 정책으로 거부하는 건 된다. 차단은 한쪽, 허용은 게이트 통과. IAM의 일반 원칙(명시적 Deny 우선)과 같은 결이다.

여기서 한 가지 짚고 갈 게 있다. “IAM에 kms:GenerateDataKey를 줘야 한다”는 설명을 자주 보는데, 항상 그런 건 아니다.

S3 SSE-KMS 버킷에 파일을 올리는 경우를 보자.

1
2
3
4
5
키 정책이 그 역할을 "키 사용자"로 지목하고 있다
  → IAM에는 s3:PutObject 만 있으면 된다. kms 권한 불필요.

키 정책에 :root statement 밖에 없다
  → IAM에 kms:GenerateDataKey 가 있어야 한다.

콘솔에서 키를 만들면서 역할을 키 사용자로 지정하면 전자가 된다. 그래서 “IAM은 안 건드렸는데 되던데?”와 “IAM에 권한 줬는데 안 되던데?”가 둘 다 나온다. 둘 다 맞는 말이고, 키 정책을 안 보면 어느 쪽인지 알 수가 없다.

나머지 자주 보는 자리들은 이렇다.

  • CloudTrail을 KMS로 암호화 → CloudTrail 서비스 주체가 키 정책에 있어야 함. 서비스는 IAM 정책이 없으니 키 정책 말고는 열어줄 데가 없다
  • 다른 계정과 스냅샷·AMI 공유 → 리소스 공유와 별개로 키도 같이 열어줘야 함. 이거 빼먹으면 상대방이 공유받고도 못 쓴다

마지막 건이 특히 자주 터진다. “AMI는 공유했는데 왜 인스턴스가 안 뜨죠?”의 답이 보통 키다.


공용 키는 권장 패턴이 아니다

사내에 공용 KMS 키가 하나 있다. 별칭 하나로 Secrets Manager, S3, CloudTrail이 같이 쓴다. 내부 문서에 “손대면 안 되는 리소스”로 적혀 있는데, 이유가 여기까지 오니 명확해졌다.

먼저 분명히 하자. 이건 권장 구성이 아닌것 같다. 여기까지 정리한 두 가지가 그대로 근거가 된다.

  • 키 정책은 키마다 정확히 하나다
  • 그 안의 Resource는 항상 “이 키”다

그러니까 권한을 적을 수 있는 가장 작은 단위가 키 하나다. 어떤 역할을 키 정책에 추가하면 그 키로 보호되는 전부가 열린다. 시크릿 하나 읽히려고 추가했는데 CloudTrail 로그까지 같이 풀리는 식이다. 폭발 반경이 키 단위로 묶여 있다.

그래서 기본은 쓰임새별로 키를 나누는 것이다. 시크릿용, 로그용, 백업용. 나눠놓으면 아래 조건들을 쓸 일도 없다. 키가 곧 경계니까.

그런데도 공용 키는 생긴다

  • 키마다 월 $1이 붙는다. 계정이 수십 개면 눈에 띄는 숫자가 된다
  • 처음엔 용도가 하나였는데 나중에 하나씩 얹힌다
  • 이미 그 키로 암호화된 데이터가 쌓이면 나누는 비용이 커진다

그리고 이게 “손대면 안 되는 리소스”로 문서에 적힌 이유이기도 하다. 키 정책 한 줄이 여러 서비스에 동시에 영향을 준다.

이미 공용 키가 있는 상태에서 권한만이라도 좁히고 싶을 때, 쓸 수 있는 조건이 두 개 있다.(참고용임 실제 적용은 안해봄)

kms:ViaService — 특정 서비스를 통한 요청만

1
2
3
4
5
"Condition": {
  "StringEquals": {
    "kms:ViaService": "s3.ap-northeast-2.amazonaws.com"
  }
}

이걸 붙이면 S3를 거친 요청일 때만 키를 쓸 수 있다. 같은 역할이 KMS API를 직접 호출해서 뭔가 복호화하려 하면 거부된다. “이 역할은 S3 쓸 때만 이 키를 쓴다”를 조건으로 못 박는 것이다. 이걸 이용한다

kms:EncryptionContext:<키> 로 리소스 하나까지

ViaService로 “Secrets Manager를 통한 요청만”까지 좁혔다고 하자. 그래도 그 키로 보호되는 시크릿 전부가 열린다. 서비스 단위라서 그렇다. 시크릿 하나 읽히려고 추가했는데 나머지 시크릿도 같이 열리는 건 그대로다.

한 칸 더 들어가는 도구가 암호화 컨텍스트다.

· AWS KMS condition keys — “You can use the kms:EncryptionContext:context-key condition key to control access to a symmetric encryption KMS key based on the encryption context in a request for a cryptographic operation.”

암호화할 때 같이 넘기는 키-값 쌍이고, 복호화할 때 똑같은 값을 넘겨야 풀린다. 여기까지는 그냥 무결성 장치처럼 보이는데, 핵심은 AWS 서비스들이 여기에 리소스 식별자를 알아서 넣어준다는 것이다. Secrets Manager가 KMS를 호출할 때 실제로 이게 실려 간다.

· Secret encryption and decryption — “Secrets Manager uses an encryption context with two name–value pairs that identify the secret and its version”

1
2
3
4
"encryptionContext": {
  "SecretARN": "arn:aws:secretsmanager:ap-northeast-2:111122223333:secret:prod/db-a1b2c3",
  "SecretVersionId": "EXAMPLE1-90ab-cdef-fedc-ba987SECRET1"
}

그러면 이걸 조건으로 걸 수 있다.

1
2
3
4
5
"Condition": {
  "StringEquals": {
    "kms:EncryptionContext:SecretARN": "arn:aws:secretsmanager:ap-northeast-2:111122223333:secret:prod/db-a1b2c3"
  }
}

공용 키를 그대로 쓰면서 그 역할은 시크릿 하나만 풀 수 있게 된다. 좁혀지는 단계를 정리하면 이렇다.

1
2
3
조건 없음                          →  그 키로 보호되는 전부
kms:ViaService                     →  Secrets Manager를 통한 것 전부
kms:EncryptionContext:SecretARN    →  그 시크릿 하나

단, 컨텍스트 키 이름은 서비스마다 다르다. Secrets Manager는 SecretARN이지만 다른 서비스는 다른 이름을 쓴다. 쓰려는 서비스가 뭘 넣는지는 그 서비스 문서에서 확인해야 한다. 그리고 이 값은 CloudTrail에 그대로 찍히니, 감사할 때 “이 키가 어느 리소스 때문에 호출됐나”를 보는 데도 쓸 수 있다.

다만 이 두 조건은 완화책이지 해법이 아니다. 조건을 빠뜨린 statement가 하나라도 있으면 거기로 다 열리고, 서비스가 늘 때마다 조건을 다시 점검해야 한다. 지금 새로 설계하는 거라면 조건을 공부하기 전에 키를 나누는 게 맞다. 키 하나에 월 $1이고 요청 과금은 별도다. 분리 비용이 권한 사고 비용보다 싸긴 함…


Grant는 또 뭔가

키 정책과 IAM 정책 말고 세 번째 통로가 있다. Grant다.

차이는 성격이다. 키 정책·IAM 정책은 사람이 써두는 정적인 문서인데, Grant는 동적으로 만들어졌다 사라지는 임시 허가다. 그리고 대부분 내가 아니라 AWS 서비스가 알아서 만든다.

기본 키 정책에 이런 statement가 있는데, 이게 그거다.

1
2
3
4
5
6
{
  "Sid": "Allow attachment of persistent resources",
  "Action": [ "kms:CreateGrant", "kms:ListGrants", "kms:RevokeGrant" ],
  "Resource": "*",
  "Condition": { "Bool": { "kms:GrantIsForAWSResource": true } }
}

kms:GrantIsForAWSResource 조건이 “AWS 서비스가 대신 요청할 때만”이라는 뜻이다. 사용자가 직접 CreateGrant를 호출하는 건 이걸로 허용되지 않는다.

암호화된 EBS 볼륨을 EC2에 붙일 때, EC2가 내 대신 grant를 만들어서 그 볼륨을 쓸 권한을 얻는다. 내가 뭘 안 해도 되는 건 이 statement 덕분이다.


그래서 SSE-KMS를 안 고른 적도 있다

7월에 인프라를 구축하면서 로그 저장소 암호화 방식을 두 개로 갈랐다.

대상선택
SSM 세션 로그 버킷SSE-S3 (KMS 미사용)
CloudTrailSSE-KMS

처음엔 “보안인데 다 KMS로 하면 되지”였다. 그런데 샌드박스에서 테스트해보니 그렇게 단순하지 않았다. SSE-S3로 가도 세션 로그를 올리는 EC2 역할에 S3 업로드 권한 + 암호화 관련 권한을 따로 줘야 했다. 여기서 SSE-KMS였으면 키 정책까지 같이 열어야 했다. 게이트가 하나 더 생기는 것이다.

기준을 이렇게 잡았다.

  • 키를 내가 통제해야 하는가 — 키 삭제·비활성화로 데이터 접근을 끊을 수 있어야 하는가
  • 키 사용 자체를 감사해야 하는가 — 누가 언제 복호화했는지가 CloudTrail에 남아야 하는가
  • 그 대가로 권한 관리가 두 겹이 되고 요청 과금이 붙는 것을 감수할 만한가

CloudTrail은 감사 증적이라 키를 분리해둘 가치가 있었다. SSM 세션 로그는 그 정도 통제가 필요하지 않다고 봤다. “보안이니까 무조건 KMS”가 아니라 통제가 필요한 만큼만 쓰는 것이 맞다고 정리했다.


정리

  • KMS는 모든 키에 키 정책이 반드시 하나 있어야 한다. S3 버킷 정책처럼 선택이 아니다
  • 키 정책이 허용하지 않으면 IAM 정책은 효과가 없다. kms:*를 줘도 안 된다. AccessDenied가 나면 IAM이 아니라 키 정책부터 본다
  • 반대로 Deny는 한쪽만 있어도 성립한다. 허용은 양쪽, 차단은 한쪽
  • "AWS": "...:root"는 루트 사용자에게 권한을 주는 게 아니다. “이 키의 권한 판단을 계정의 IAM 정책에 위임한다”는 스위치다. 지우면 그 계정 IAM이 전부 무력화된다
  • 그 statement를 남겨두는 건 자물쇠에 갇히지 않기 위해서다. 특정 역할만 넣어뒀다가 그 역할을 지우면 키가 관리 불가가 되고, AWS Support를 불러야 한다
  • 키 관리자는 키 정책을 바꿀 수 있어서 자기 권한을 스스로 늘릴 수 있다. 관리자/사용자 분리를 과신하면 안 된다
  • 키 정책은 리전 단위다. IAM처럼 글로벌이 아니다
  • 공용 키는 권한 단위가 키 하나라, 한 명을 추가하면 그 키로 보호되는 전부가 열린다. kms:ViaService나 kms:EncryptionContext로 좁히든가, 아니면 서비스별로 키를 나눈다
  • 실제 암호화는 KMS가 아니라 데이터 키로 서비스 쪽에서 한다(봉투 암호화). 그래서 업로드에 필요한 권한이 kms:Encrypt가 아니라 kms:GenerateDataKey다
  • “보안이니까 무조건 SSE-KMS”는 아니다. 키 통제와 사용 감사가 필요한 만큼만. 고르는 순간 권한이 두 겹이 된다

IAM을 정리할 때는 “요청 하나가 어떻게 인증되고 인가되는가”를 따라갔는데, KMS는 그 위에 문이 하나 더 있는 구조였다. 그 문의 존재를 모르면 AccessDenied가 영원히 안 풀린다. 미뤄둔 deep dive를 이제야 했다.

참고

This post is licensed under CC BY 4.0 by the author.