Files
keycloak-pattern/docs/experiment-b6-key-rotation.md
T
DongHyeonkaandClaude Opus 5 74c9b3cea7 docs: replace prose placeholders in reproduction steps with executable commands
The audit found ~80 placeholders, and the damaging ones were where the
measuring apparatus itself was prose rather than a command:

  a6  "( curl ... ) & 를 20개 띄우고 wait"  — the 22.2s headline came from this
  a3  "<로그인 반복, sid 를 /tmp/sids 에>"  — the whole RPO measurement
  a3  "<sid 목록>"                          — the control it is compared against
  a5  "<수신 파드IP>"                       — the injection
  a8  writes /tmp/tok, reads /tmp/rt        — self-inconsistent, sent an empty token
  b3  $KC / $RT / $NEW never assigned
  c2  bare kcadm.sh with no kubectl exec
  a1  conntrack tuples written by hand, though the direction flips per restart

Each is now a shell-expandable form: pod IPs from jsonpath, the admin password
from the secret, ids from kcadm --format csv, conntrack tuples derived from
"conntrack -L" with awk rather than transcribed.

Then the rewritten commands were executed against the live cluster, and one
of them failed — the 20-way load generator, written as "kubectl run --rm -i",
lost its output stream twice in a row. That is a trap this series already hit
once, and the rewrite reintroduced it. A-6 now uses a resident probe pod that
collects into a file and is cat-ed once; verified 20/20 lines.

Evidence: docs/evidence/followup/05-command-reproducibility.txt

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 17:01:54 +09:00

8.6 KiB
Raw Blame History

B-6 — key 를 교체하면 옛 것으로 만든 것은 어떻게 되는가 → Q3 미지수 3

브랜치 feature/keycloak-b6-key-rotation · 증거 docs/evidence/b6-key-rotation/ · 2026-09-04 15:5016:00 KST

선행: B-2 — 토큰이 평문임을 확인했다

대응 질문 — Q3 미지수 3

"암호화 key 를 어디에 두고 어떻게 교체하게 되는가. 교체하는 동안 이전 key 로 저장된 값은 어떻게 읽는가."


구조

B-6 구조 — 겹침 구간과 제거 시점

다이어그램 규약은 diagrams/_style.md. 실험대 전체 구조는 diagrams/lab-topology.svg.


0. 결론부터

질문이 두 갈래로 나뉜다.

상태
① 토큰 저장소의 암호화 key ★ 존재하지 않는다. B-2 에서 평문임을 확인했다
② 토큰 서명 key (Keycloak realm) 존재하고 회전 가능하다 — 측정했다

②를 측정한 결과가 ①을 설계할 때 그대로 쓰인다.

   키 추가  →  무중단.  JWKS 에 옛 키와 새 키가 함께 남는다
   키 제거  →  ★ 즉시 파괴적.  옛 키로 서명된 토큰이 곧바로 401

"교체하는 동안 이전 key 로 저장된 값은 어떻게 읽는가" 의 답은 "두 key 를 동시에 들고 있으면 읽힌다" 이고, 위험한 것은 교체가 아니라 옛 key 를 언제 버리느냐다.


1. ① 암호화 key 는 애초에 없다

B-2 에서 확인했다.

select convert_from(refresh_token_value, 'UTF8') from oauth2_authorized_client;
 eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJlMmUz...

bytea 안이 JWT 문자열 그대로다. 암호화가 없으므로 교체할 key 도 없다.

Q3 는 "Access Token 과 Refresh Token 을 Redis 에 저장할 경우 Token 을 어떤 방식으로 암호화할지" 를 미지수로 뒀다. 먼저 확인해야 할 것은 "지금은 암호화가 없다" 이고, 그것을 B-2 에서 측정했다.

암호화를 넣는다면 회전 설계는 ②에서 본 모양을 따라야 한다 — 읽기는 여러 key 를 받아들이고, 쓰기는 한 key 만 쓴다.


2. ② 서명 key 회전을 실측한다

회전 전

  발급 토큰의 kid: OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM
  JWKS 의 RS256 키 수: 1
  /api/me  HTTP 200

회전 — 우선순위가 높은 공급자를 추가한다

kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh create components \
  -r keycloak-patterns \
  -s name=rsa-rotated -s providerId=rsa-generated \
  -s providerType=org.keycloak.keys.KeyProvider \
  -s 'config.priority=["200"]' -s 'config.algorithm=["RS256"]' -s 'config.keySize=["2048"]'

Keycloak 의 키 회전은 "바꾸기"가 아니라 "더 높은 우선순위로 추가하기"다.

  RS256 키 수: 1 → 2
  kid 목록:
    1B4AQHoxZvFaQi1tc1byz8ifU-nYFB6engD4YB4Fz84    ← 새 키 (우선순위 200)
    OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM    ← 옛 키 (우선순위 100, 남아 있다)

  새 토큰의 kid: 1B4AQHoxZvFaQi1tc1byz8ifU-nYFB6engD4YB4Fz84

결과 — 무중단이다

  옛 토큰  /api/me  HTTP 200
  새 토큰  /api/me  HTTP 200

둘 다 통한다. 새 토큰은 새 키로 서명되고, 옛 토큰은 JWKS 에 아직 있는 옛 키로 검증된다.


3. 옛 키를 제거하면 — 여기가 진짜 질문이다

kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  delete components/980ee9b7-... -r keycloak-patterns
  RS256 키 수: 2 → 1

  옛 토큰  /api/me  HTTP 401     ← ★ 즉시 깨진다
  새 토큰  /api/me  HTTP 200

리소스 서버를 재시작해 JWKS 캐시를 비운 뒤에도 같다.

  옛 토큰  /api/me  HTTP 401
  새 토큰  /api/me  HTTP 200

캐시가 구해주지 않는다

처음에는 "리소스 서버가 JWKS 를 캐시하고 있으니 한동안은 통하겠지" 라고 예상했다. 아니었다. 제거 직후 바로 401 이다.

이유는 Spring 의 NimbusJwtDecoder 동작에 있다 — 모르는 kid 를 만나면 JWKS 를 다시 가져온다. 그래서 캐시가 오히려 빨리 갱신되고, 결과적으로 제거가 즉시 반영된다.

캐시를 유예 기간으로 기대하면 안 된다. 유예는 옛 키를 JWKS 에 남겨두는 기간으로 만들어야 한다.


4. 개념

kid — key ID

{"alg":"RS256","typ":"JWT","kid":"OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM"}

토큰 헤더에 어느 키로 서명했는지가 적혀 있다. 검증하는 쪽은 JWKS 에서 그 kid 를 찾아 공개키를 얻는다.

kid 가 있기 때문에 여러 키를 동시에 운용할 수 있다.

안전한 회전의 모양

   t0   키 A 만 있다.  발급: A,  검증: A
   t1   키 B 추가.     발급: B,  검증: A + B      ← 겹치는 구간
   t2   키 A 제거.     발급: B,  검증: B

   ★ t1 ~ t2 사이가 "A 로 서명된 것이 살아 있는 기간" 보다 길어야 한다

겹치는 구간의 최소 길이 = 옛 키로 서명된 것 중 가장 오래 사는 것의 수명.

이 실험대에서
access token 60초
refresh token 1800초 (30분)
필요한 겹침 최소 30분

Q3 가 물은 "교체하는 동안" 이 바로 이 구간이고, 길이를 정하는 것은 key 가 아니라 그 key 로 만든 것의 수명이다.

①에 적용하면

토큰 저장소를 암호화한다면 같은 구조가 필요하다.

   쓰기: 새 key 하나로만
   읽기: 새 key + 옛 key(들)      ← key 에도 식별자가 필요하다
   제거: 옛 key 로 암호화된 마지막 항목이 만료된 뒤

저장된 값에 kid 에 해당하는 표시가 없으면 회전이 불가능하다. 암호화를 설계할 때 key 식별자를 값과 함께 저장해야 하는 이유다.


5. 겪은 문제

kcadm get components 가 조용히 빈 결과

kcadm.sh get components -r keycloak-patterns -q type=org.keycloak.keys.KeyProvider

-q type=... 로는 아무것도 안 나왔다. --fields 를 붙여 전체를 받아야 보였다. 필터가 안 먹었는데 오류도 없었다 — A층에서 반복해 만난 "조용한 실패"다.



증거 파일

증거 수집 시각: 2026-09-04 14:30 14:32 KST (파일 mtime 기준. 문서 상단의 시각 표기는 작성 시점이라 다를 수 있다.)

파일 종류
01-before-rotation.txt 터미널 원문
02-rotation.txt 터미널 원문
03-old-key-removed.txt 터미널 원문

파일별 상세는 evidence/b6-key-rotation/README.md.

6. 재현 절차 (명령어)

# 1. 현재 키와 토큰의 kid
kcadm.sh get keys -r keycloak-patterns
curl -s $KC/protocol/openid-connect/certs | tr ',' '\n' | grep kid

# 2. 회전 — 더 높은 우선순위로 추가한다 (바꾸는 것이 아니다)
kcadm.sh create components -r keycloak-patterns \
  -s name=rsa-rotated -s providerId=rsa-generated \
  -s providerType=org.keycloak.keys.KeyProvider \
  -s 'config.priority=["200"]' -s 'config.algorithm=["RS256"]' -s 'config.keySize=["2048"]'

# 3. 옛 토큰이 아직 통하는지 (통해야 정상)
curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $OLD" http://echo...:8081/api/me

# 4. 옛 공급자 목록 — -q 필터는 안 먹는다. --fields 로 전체를 본다
kcadm.sh get components -r keycloak-patterns --fields id,name,providerId

# 5. 제거하고 다시 확인 — 여기서 401 이 나와야 정상이다
kcadm.sh delete components/$(kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh get components -r keycloak-patterns --fields id,name,providerId | grep -B2 '"name" : "rsa-generated"' | grep '"id"' | cut -d'"' -f4) -r keycloak-patterns

7. 다음 실험에 남기는 것

실험 이 실험이 준 것
B-7 oauth2-proxy cookie secret 같은 모양의 문제. 다만 거기는 겹침 구간을 만들 수 있는가가 관건
D-2 버전 업그레이드 Redis 의 Java 직렬화 세션(B-1)도 같은 "옛 형식을 읽을 수 있는가" 문제다
설계 암호화를 넣는다면 값과 함께 key 식별자를 저장해야 회전할 수 있다