Files
keycloak-pattern/docs/guides/experiments/b6-key-rotation.md
T
DongHyeonkaandClaude Opus 5 6f6ab86345 docs(guides): reproduction guides for all 26 experiments
Written by subagents running under the writing-practitioner-guides skill,
one guide per experiment, 22,566 lines. Each walks a reader from baseline
capture through injection, injection verification, observation and recovery.

Section 3 carries the weight in most of them. Injection failed silently nine
times in this lab, and a failed injection looks exactly like no effect — so
the guides verify the target is actually in the intended state before
reading any result. A-4 makes virsh list the only proof because the node
reads Ready for 40 seconds after the machine is off; A-5 makes the packet
counter the sole go/no-go because a rule on the wrong node produces an empty
result that reads like a finding; A-6 quotes the run where 적용완료 was
printed between four Cannot find device "eth0" lines.

The traps the guides are built around are ones that invert a conclusion
rather than merely annoy:

  A-0   emptying the session table without a restart leaves cache entries
        that get counted as replication arriving
  A-2   dropping -o /dev/null fuses body and status into one string
  A-3   presence of "ready to accept connections" instead of its timestamp
  B-2   row count alone reads an UPDATE as nothing having happened
  B-4   tr ',' '\n' splits ["admin","editor"] so only admin is seen
  B-7   no login screen means the cookie died and SSO re-authenticated
  C-1   counting sessions without joining realm counts your own kcadm one
  D-1   kubectl exec without -i restores nothing and still exits 0
  D-4a  "ran with error output" is what success looks like

Every quoted block is copied from docs/evidence/ and marked 실측; reshaped
commands are marked 미검증 rather than passed off as measured. Where a source
document carries a ★ correction the guides follow the corrected claim — A-7's
REVOKED_TOKEN hypothesis, C-1's session count, B-2's schema attribution.

Two hazards are stated rather than smoothed over: B-6 deletes a key that
cannot be recreated, and D-1/D-4 need host sudo, which asks for a password,
so those steps say a person must type them.

Audit over all 26: 672 interpretation pairs, 486 evidence citations, 117
undo sections, and zero occurrences of the patterns the skill forbids —
no python data processing, no deprecated kubectl get endpoints, no
placeholders, no bare kcadm.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 18:29:00 +09:00

32 KiB
Raw Blame History

B-6 재현 가이드 — 서명 키를 회전하고, 옛 키를 버리는 순간을 직접 본다

해설 문서: docs/experiment-b6-key-rotation.md · 증거 원문: docs/evidence/b6-key-rotation/

이 가이드가 끝나면

당신 터미널에서 이것들을 직접 본다.

보게 되는 것 어디서
토큰 헤더에 어느 키로 서명했는지가 적혀 있는 것 JWT 첫 토막 (kid)
키를 「추가」했는데 옛 키가 JWKS 에 그대로 남는 것 .../openid-connect/certs
옛 토큰과 새 토큰이 둘 다 200 인 무중단 구간 echo /api/me
옛 키를 지운 직후 옛 토큰이 401 이 되는 것 같은 엔드포인트
리소스 서버를 재시작해도 여전히 401 인 것 rollout restart deploy/echo
kcadm 의 필터가 오류 없이 빈 결과를 주는 것 get components -q type=...

전제

  • 05-keycloak 가 끝나 있고, realm keycloak-patterns 에 클라이언트 bff-confidential 과 사용자 labuser 가 있다.
  • 리소스 서버(echo, 네임스페이스 header-lab)가 떠 있다. 이 실험의 401/200 은 전부 그 앱이 판정한다.
  • 명령은 kc-lab-1 에서 친다. kubectlsudo 로 쓴다 (kubeconfig 를 사용자 홈에 복사해 뒀다면 sudo 는 빼도 된다).
  • Keycloak 이미지에는 curlwget 도 없다(exit 127). 그래서 JWKS 와 토큰은 kc-lab-1 호스트에서 공개 이름으로 친다. kcadm.sh 만 파드 안에서 돈다 — 항상 kubectl exec 로 감싼다.
  • 이 실험대에는 jq 가 없다. JSON 은 trgrep 으로 자른다.

주의 — 이건 되돌릴 수 없는 실험이다

서명 키 공급자를 실제로 지운다. 지운 키는 돌아오지 않는다. 같은 이름으로 공급자를 다시 만들어도 새 키 쌍이 생기고 kid 가 다르다. 그러니 옛 키로 서명된 토큰은 영구히 검증되지 않는다.

실험대에서만 한다. 전 구간 약 15분이고, 3절까지는 아무것도 안 깨진다. 파괴가 시작되는 지점은 4. 관찰 이며, 그 앞에 경고를 다시 붙여 두었다.

표시 규약

표시
실측 2026-09-04 14:3014:32 KST 수집 기록의 출력 원문. 증거 파일에 그대로 있다
형태 값이 매번 달라지는 출력. 모양만 보이고 값은 당신 것과 다르다
미검증 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행 기록에 이 명령의 출력은 없다

해설 문서 머리에 적힌 15:5016:00 KST문서를 쓴 시각이고, 증거 파일의 mtime 은 14:3014:32 KST 다. 실측으로 인용하는 것은 뒤쪽이다.

kid·컴포넌트 id 는 당신 환경에서 다르다. 이 문서는 자리표시자(<...>)를 쓰지 않는 대신, 그 값을 뽑는 명령을 먼저 적는다. 예시로 실린 값은 전부 위 수집 기록의 실제 값이다.


0. 왜 이 실험을 하는가

Q3 의 미지수 3 은 이렇게 물었다.

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

질문이 두 갈래로 갈린다.

상태
① 토큰 저장소의 암호화 key 존재하지 않는다. B-2 에서 bytea 안이 JWT 문자열 그대로임을 확인했다
② 토큰 서명 key (Keycloak realm) 존재하고 회전 가능하다 — 이 실험이 잰다

①이 없으므로 교체할 것도 없다. 그래서 이 가이드는 ②만 친다. 그리고 ②에서 본 모양이 나중에 ①을 설계할 때 그대로 쓰인다.

그리고 이 실험은 예측이 틀린 실험이다.

예측 리소스 서버가 JWKS 를 캐시하니, 옛 키를 지워도 한동안은 통할 것
실측 유예가 없다. 제거 직후 바로 401 이다

이유는 뒤에서 본다. 캐시를 유예 기간으로 기대하면 안 된다는 것이 이 실험이 남긴 한 줄이고, 그것을 당신 터미널에서 확인하는 것이 이 가이드의 목적이다.

핵심은 두 동작을 분리해서 보는 것이다.

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

「교체」라는 한 단어가 실제로는 서로 성질이 정반대인 두 조작이다. 회전이 위험한 게 아니라 옛 키를 언제 버리느냐가 위험하다.


1. 기준선 — 아무것도 바꾸기 전에

시험군만 재는 측정은 측정이 아니다. 제거 후에 볼 것을 제거 전에 똑같은 명령으로 먼저 봐 둔다. 그래야 「원래 그랬던 것」과 「내가 바꾼 것」이 구별된다.

넓은 것부터 좁혀 간다.

kcadm 로그인 → 키 공급자 목록 → JWKS 원문 → 토큰의 kid → 그 토큰이 통하는가

1-1. kcadm 을 먼저 로그인시킨다

kcadm.sh파드 안 파일에 세션을 저장한다. 파드가 재시작되면 사라지고, 그 뒤 모든 명령이 401 로 떨어진다. 맨 앞에서 한 번 해 둔다.

하기

sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  config credentials --server http://localhost:8080 --realm master --user admin \
  --password "$(sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
                -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)"

어디를 봐야 하는가아무것도 안 나오면 성공이다. 실패하면 한 줄 오류가 뜬다.

비밀번호를 화면에 찍지 않는다. 명령 치환으로 넘기므로 값은 터미널에도 셸 히스토리에도 남지 않는다. 존재만 확인하고 싶으면 길이만 본다.

sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
  -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c

1-2. 지금 어떤 키 공급자가 있나

확인 — 통째로 받아서 눈으로 본다

sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  get components -r keycloak-patterns --fields id,name,providerId

형태 — JSON 배열이 여러 줄로 나온다. providerIdrsa-generated 인 항목이 서명 키 공급자이고, hmac-generated·aes-generated 등이 함께 나온다.

어디를 봐야 하는가"name" : "rsa-generated" 인 항목의 "id". 4절에서 지울 대상이 이것이다. 지금 적어 둔다.

★ 여기서 조용한 실패를 하나 만난다

「키 공급자만 걸러 보자」는 자연스러운 시도가 빈 결과를 준다.

미검증 — 원래 실행에서 이렇게 쳤고 아무것도 안 나왔다

sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  get components -r keycloak-patterns -q type=org.keycloak.keys.KeyProvider

오류도 종료코드도 없이 비어 있다. 「키 공급자가 하나도 없구나」로 읽으면 이 실험 전체가 무너진다. -q 필터를 믿지 말고 --fields 로 전체를 받는다.

A층 내내 반복해 만난 유형이다 — 조용한 실패. 빈 출력은 「없다」가 아니라 「이 명령으로는 안 보인다」일 수 있다. 다른 명령으로 한 번 더 확인한다.

1-3. JWKS 원문을 한 번 통째로 본다

확인

curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs

줄바꿈 없이 한 줄로 길게 나온다. 그래도 처음 한 번은 그대로 본다. 어떤 필드가 들어 있는지 알아야 다음부터 무엇으로 걸러야 할지 안다.

실측 — 첫머리. 01-before-rotation.txt 에 남은 조각 그대로다

{"keys":[{"kid":"gokjn0zFUok8r7JVqW1cxuyojH1bTT87vzfQG9RrFX4"

그 뒤로 kty·alg·use·n·e 가 이어지고 다음 키가 온다. kid 마다 alg 가 따로 붙는다 — 이 사실이 바로 아래에서 쓰인다.

읽을 만하게 자른다. jq 가 없으므로 tr 로 쉼표를 줄바꿈으로 바꾼다.

확인

curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \
  | tr ',' '\n' | grep kid

실측01-before-rotation.txt

  JWKS kid 목록:
    {"keys":[{"kid":"gokjn0zFUok8r7JVqW1cxuyojH1bTT87vzfQG9RrFX4"
    {"kid":"OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM"

어디를 봐야 하는가kid 는 두 개인데 이 실험이 세는 RS256 키는 하나다.

같은 파일의 바로 윗줄이 그렇게 말한다.

실측

  JWKS 의 RS256 키 수: 1

세는 단위가 다르다. JWKS 에는 서명 키만 실리는 게 아니다. 이 realm 에서는 암호화용 키(RSA-OAEP 계열)가 함께 실려 있고, 그것도 kid 를 갖는다. grep kid | wc -l 로 세면 서명 키 수를 과다 계산한다.

알고리즘까지 보고 세려면 키 단위로 잘라야 한다. JWKS 는 키 하나가 } 로 끝나므로 tr '}' 로 자르면 한 줄이 한 키가 된다. 미검증

curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \
  | tr '}' '\n' | grep -c RS256

Keycloak 자신에게 묻는 편이 더 확실하다 — 이쪽이 1순위 도구다. 미검증

sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  get keys -r keycloak-patterns

어디를 봐야 하는가 — 키마다 붙는 algorithmstatus. RS256 이면서 ACTIVE 인 것이 지금 서명에 쓰이는 키다.

이 두 명령은 원래 실행 기록에 출력이 없다. 당신 출력에서 필드 이름을 직접 확인한다. 위에 인용한 「RS256 키 수: 1」만이 실측이다.

1-4. 토큰을 하나 받고, 그 토큰의 kid 를 본다

하기 — direct grant 로 받는다. 클라이언트 비밀은 Secret 에서 꺼내 쓴다

KC=https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/token
CS=$(sudo kubectl -n keycloak-lab get secret bff-secrets \
     -o jsonpath='{.data.KEYCLOAK_CLIENT_SECRET}' | base64 -d)
OLD=$(curl -s -X POST "$KC" \
  -d grant_type=password -d client_id=bff-confidential -d "client_secret=$CS" \
  -d username=labuser -d password=labpass -d scope=openid \
  | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')
echo "${#OLD}자"

형태

2043자

0자 로 나오면 토큰을 못 받은 것이다. 변수에 담지 말고 응답을 그대로 찍어 본문을 읽는다.

이 토큰이 이 실험의 시험체다. 변수 이름을 OLD 로 둔 이유는, 회전이 끝난 뒤에도 이것이 「옛 키로 서명된 토큰」으로 남아야 하기 때문이다. 중간에 다시 받으면 새 키로 서명되어 실험이 성립하지 않는다.

확인 — JWT 의 첫 토막이 헤더다. 거기 kid 가 있다

echo "$OLD" | cut -d. -f1 | tr '_-' '/+' | base64 -d 2>/dev/null; echo

형태

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

실측01-before-rotation.txt

  발급 토큰의 kid: OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM

어디를 봐야 하는가kid 가 1-3 의 JWKS 목록에 있는 값과 같은가. 이것이 이 실험의 뼈대다. 토큰이 자기 서명 키를 스스로 밝히고 있다.

base64 패딩 때문에 끝이 깨져 보일 수 있다(2>/dev/null 이 그 불평을 지운다). 헤더는 짧아서 대개 온전히 보인다.

개념 — kid 가 있어서 여러 키를 동시에 운용할 수 있다

무엇인가. kid 는 key ID 다. 서명한 쪽이 어느 키를 썼는지를 토큰 헤더에 적어 준다. 검증하는 쪽은 JWKS 에서 그 kid 를 찾아 공개키를 얻는다.

왜 여기 나오나. kid 가 없다면 검증자는 「지금 유효한 키」 하나만 알 수 있고, 키가 바뀌는 순간 옛 토큰은 전부 죽는다. kid 가 겹침 구간을 가능하게 한다.

없거나 틀리면. 겹침이 불가능해진다 — 그 사례를 B-7 에서 본다. oauth2-proxy 의 쿠키에는 kid 에 해당하는 표시가 없고, 그래서 --cookie-secret 도 단수다.

1-5. 그 토큰이 지금 통하는가 — 대조군

이 절을 건너뛰면 뒤의 401 은 아무 의미가 없다. 「원래 안 됐던 것」과 「내가 깨뜨린 것」을 구별할 수단이 이것뿐이다.

먼저 응답을 통째로 한 번 본다.

확인

curl -s -i -H "Authorization: Bearer $OLD" https://app1.hyeonworks.com/api/me

어디를 봐야 하는가 — 상태줄과 본문. 200 이면 subject 같은 클레임이 돌아온다. 401 이면 WWW-Authenticate 헤더에 이유가 붙는다. 이 헤더를 한 번 봐 두면 뒤에서 401 이 났을 때 「왜」를 묻는 자리가 생긴다.

이제부터는 여러 번 비교해야 하므로 코드만 뽑는다.

확인

curl -s -o /dev/null -w 'old %{http_code}\n' \
  -H "Authorization: Bearer $OLD" https://app1.hyeonworks.com/api/me

실측01-before-rotation.txt

=== [2] 그 토큰이 지금 통하는가 (리소스 서버) ===
  /api/me  HTTP 200

이 결과가 의미하는 것 — 회전 전에는 통한다. 이 200 이 기준선이다.

원래 실행은 클러스터 안에서 http://echo.header-lab.svc:8081/api/me 를 쳤다. 이 가이드가 공개 이름을 쓰는 것은 kc-lab-1 에서는 클러스터 DNS 가 안 풀리기 때문이다. app1.hyeonworks.com/api 는 Ingress 가 같은 echo 로 보내므로 도달하는 앱은 같다. 인용한 HTTP 200 은 원래 실행의 값이다.

access token 은 60초짜리다(이 realm 은 accessTokenLifespan=60). 1분을 넘기면 회전과 무관하게 401 이 난다. 뒤에서 401 을 만나면 먼저 「만료인가 키 문제인가」를 갈라야 한다 — 3-3 에 그 방법을 적어 두었다.


2. 주입 — 우선순위가 더 높은 키 공급자를 추가한다

여기부터 상태가 바뀐다. 되돌리는 명령을 먼저 읽어 둔다.

되돌리기 — 방금 만든 공급자를 지우면 원래대로 돌아간다. id 는 2-2 가 출력하는 값이고, 그 줄을 그대로 옮겨 친다. 원래 실행에서는 7902af43-a0cc-4ebd-ad25-04d563854d16 이었다

sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  delete components/7902af43-a0cc-4ebd-ad25-04d563854d16 -r keycloak-patterns

2절만 되돌리는 것은 안전하다. 되돌릴 수 없는 것은 4절이다.

2-1. 무엇을 하는 것인가 — 먼저 읽는다

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

기존 공급자는 그대로 두고, priority 가 더 큰 공급자를 하나 더 만든다. 그러면 발급은 새 키로 가고, 검증은 둘 다 받는다. 옛 키는 아무 데도 안 갔다.

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

이 실험이 재는 것은 t1 이 무중단인가(2~3절)와, t2 가 언제 안전한가(4절)다.

2-2. 추가한다

하기

sudo 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"]'
date '+%H:%M:%S 추가'

실측02-rotation.txt

=== [3] 키 회전 — 우선순위가 더 높은 RSA 공급자를 추가한다 ===
Created new component with id '7902af43-a0cc-4ebd-ad25-04d563854d16'

어디를 봐야 하는가돌아온 id 를 적어 둔다. 되돌릴 때 쓴다. 그리고 config.priority기존 공급자보다 큰지 — 기본값은 100 이고 여기서는 200 을 줬다. 낮게 주면 새 키는 만들어지지만 발급에 쓰이지 않아 3-2 에서 kid 가 안 바뀐다.

config.* 값이 대괄호로 감싼 배열인 것에 주의한다. -s config.priority=200 처럼 쓰면 형이 안 맞는다. Keycloak 컴포넌트 설정은 값이 전부 문자열 목록이다.


3. 추가가 실제로 걸렸는지 확인한다

결과를 해석하기 전에, 주입이 의도한 것만 건드렸는지 먼저 본다.

3-1. JWKS 에 두 키가 함께 있는가

확인 — 1-3 과 똑같은 명령을 다시 친다

curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \
  | tr ',' '\n' | grep kid

실측02-rotation.txt

=== [4] 회전 후 JWKS — 옛 키가 남아 있는가 ===
  RS256 키 수: 2
  kid 목록:
    {"keys":[{"kid":"1B4AQHoxZvFaQi1tc1byz8ifU-nYFB6engD4YB4Fz84"
    {"kid":"gokjn0zFUok8r7JVqW1cxuyojH1bTT87vzfQG9RrFX4"
    {"kid":"OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM"

어디를 봐야 하는가kid(OY-caYDN…)가 목록에 그대로 있다. 새 것이 하나 늘었고, 아무것도 사라지지 않았다.

이 결과가 의미하는 것 — 「회전」이라는 말과 달리 아무것도 교체되지 않았다. JWKS 는 「지금 검증에 쓸 수 있는 키 전부」를 싣는 목록이고, 추가는 그 목록을 늘릴 뿐이다.

3-2. 새 토큰은 어느 키로 서명되는가

하기 — 지금 새로 하나 받는다. OLD 은 건드리지 않는다

NEW=$(curl -s -X POST "$KC" \
  -d grant_type=password -d client_id=bff-confidential -d "client_secret=$CS" \
  -d username=labuser -d password=labpass -d scope=openid \
  | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')
echo "$NEW" | cut -d. -f1 | tr '_-' '/+' | base64 -d 2>/dev/null; echo

실측02-rotation.txt

=== [5] 새 토큰은 어느 키로 서명되는가 ===
  새 토큰의 kid: 1B4AQHoxZvFaQi1tc1byz8ifU-nYFB6engD4YB4Fz84

어디를 봐야 하는가kid우선순위 200 짜리 새 키로 바뀌었다.

이 결과가 의미하는 것 — 발급은 우선순위가 가장 높은 키로 간다. 여기서 kid 가 안 바뀌었다면 priority 를 낮게 준 것이다. 2-2 로 돌아간다.

3-3. ★ 둘 다 통하는가 — 무중단 구간의 실측

확인

curl -s -o /dev/null -w 'old %{http_code}\n' \
  -H "Authorization: Bearer $OLD" https://app1.hyeonworks.com/api/me
curl -s -o /dev/null -w 'new %{http_code}\n' \
  -H "Authorization: Bearer $NEW" https://app1.hyeonworks.com/api/me

실측02-rotation.txt

=== [6] ★ 회전 전에 발급된 토큰은 아직 통하는가 ===
  옛 토큰  /api/me  HTTP 200
  새 토큰  /api/me  HTTP 200

어디를 봐야 하는가둘 다 200.

이 결과가 의미하는 것키 추가는 무중단이다. 새 토큰은 새 키로 서명되고, 옛 토큰은 JWKS 에 아직 있는 옛 키로 검증된다. 사용자는 아무것도 못 느낀다.

여기서 old 가 401 이면 두 가지 중 하나다. ① 토큰이 만료됐다(60초). ② 뭔가 다른 것을 건드렸다. 가르는 법 — 옛 토큰의 exp 를 본다. JWT 의 가운데 토막이 클레임이다.

echo "$OLD" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null; echo
date +%s

exp 가 지금보다 작으면 만료다. 1-4 로 돌아가 다시 받되, 이번에는 추가 전에 받아야 「옛 키로 서명된 토큰」이 된다.


4. 관찰 — 옛 키를 제거한다

★ 여기서부터 되돌릴 수 없다

이 절이 이 실험의 본 시험이다. 그리고 되돌릴 수 없다. 지우는 것은 키 공급자이고, 그 안의 개인키가 함께 사라진다. 같은 이름으로 다시 만들어도 다른 키 쌍이 생긴다.

계속하기 전에 확인한다.

  • 이 realm 이 실험대 전용인가
  • 지금 살아 있는 세션 중에 잃으면 곤란한 것이 있는가
  • 3-3 의 old 200실제로 봤는가 (안 봤다면 401 이 나와도 원인을 못 가른다)

4-1. 지울 대상을 정확히 고른다

확인 — 1-2 와 같은 명령. -q 는 여전히 안 먹는다

sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  get components -r keycloak-patterns --fields id,name,providerId

어디를 봐야 하는가"name" : "rsa-generated" 인 항목의 id. 방금 만든 것은 "name" : "rsa-rotated" 다. 둘을 바꿔 지우면 실험이 뒤집힌다.

목록이 길면 그 항목 주변만 잘라 본다. "id""name" 보다 에 있다.

sudo 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"'

원래 실행에서 지운 것은 이것이다.

실측03-old-key-removed.txt

=== [7] 옛 RSA 공급자(980ee9b7 = OY-caYDN 키) 제거 ===
  제거 완료

980ee9b7… 로 시작하는 것이 옛 공급자의 id 이고, 그것이 OY-caYDN… 키를 갖고 있었다. 당신 환경의 id 는 다르다. 증거에 남은 것도 앞 8자뿐이니 전체 id 는 위 명령의 출력에서 그대로 옮겨 온다.

4-2. 지운다

하기 — 위 출력에서 고른 id 를 변수에 넣고 지운다

OLDID=980ee9b7-...        # ← 4-1 의 출력에서 그대로 옮긴다. 환경마다 다르다

sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  delete components/"$OLDID" -r keycloak-patterns
date '+%H:%M:%S 제거'

어디를 봐야 하는가 — 조용히 끝나면 성공이다. 시각을 적어 둔다.

4-3. JWKS 에서 사라졌는가

확인 — 또 같은 명령이다

curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \
  | tr ',' '\n' | grep kid

실측03-old-key-removed.txt

=== [8] JWKS 에서 사라졌는가 ===
  RS256 키 수: 1
    {"keys":[{"kid":"1B4AQHoxZvFaQi1tc1byz8ifU-nYFB6engD4YB4Fz84"
    {"kid":"gokjn0zFUok8r7JVqW1cxuyojH1bTT87vzfQG9RrFX4"

어디를 봐야 하는가OY-caYDN… 이 없다. RS256 은 다시 1개다. gokjn0… 은 처음부터 끝까지 그대로 있다 — 서명 키가 아니기 때문이다(1-3).

4-4. ★ 옛 토큰은 이제 어떻게 되는가

확인 — 3-3 과 똑같은 두 줄

curl -s -o /dev/null -w 'old %{http_code}\n' \
  -H "Authorization: Bearer $OLD" https://app1.hyeonworks.com/api/me
curl -s -o /dev/null -w 'new %{http_code}\n' \
  -H "Authorization: Bearer $NEW" https://app1.hyeonworks.com/api/me

실측03-old-key-removed.txt

=== [9] ★ 옛 키로 서명된 토큰은 이제 어떻게 되는가 ===
  옛 토큰  /api/me  HTTP 401  (캐시가 살아 있으면 아직 통할 수 있다)
  새 토큰  /api/me  HTTP 200

어디를 봐야 하는가옛 토큰 401, 새 토큰 200.

이 결과가 의미하는 것 — 제거는 즉시 반영된다. 괄호 안의 「캐시가 살아 있으면 아직 통할 수 있다」는 측정하기 전에 적어 둔 예상이고, 옆의 401 이 그 예상을 부정한 값이다. 증거 파일에 예상과 결과가 나란히 남아 있는 셈이다.

새 토큰도 401 이면 제거를 잘못했다 — 새 공급자를 지운 것이다. kid 를 다시 확인한다(3-2). 아니면 그냥 만료다(3-3 의 박스).

4-5. 캐시가 구해주지 않는다 — 재시작으로 확인한다

여기까지 보면 「리소스 서버가 아직 JWKS 를 캐시하고 있어서 우연히 401 인가?」 라는 의심이 남는다. 캐시를 비워 보면 갈린다.

하기

sudo kubectl -n header-lab rollout restart deploy/echo
sudo kubectl -n header-lab rollout status deploy/echo --timeout=180s

실측03-old-key-removed.txt

=== [10] 리소스 서버를 재시작해 JWKS 캐시를 비우면 ===
deployment "echo" successfully rolled out
  옛 토큰  /api/me  HTTP 401
  새 토큰  /api/me  HTTP 200

어디를 봐야 하는가재시작 전과 후가 같다. 401 / 200.

이 결과가 의미하는 것 — 401 은 캐시 상태와 무관하다. 캐시는 유예를 주지 않았다.

왜 그런가

Spring 의 NimbusJwtDecoder모르는 kid 를 만나면 JWKS 를 다시 가져온다. 캐시는 「이미 아는 키를 다시 안 받으려는」 장치이지 「옛 키를 붙잡아 두는」 장치가 아니다.

   옛 토큰 도착
      │
      ├─▶ kid = OY-caYDN…  →  캐시에 없다
      │                          │
      │                          └─▶ JWKS 를 다시 가져온다 (여기서 오히려 빨리 갱신된다)
      │
      └─▶ 새로 받은 JWKS 에도 없다  →  401

캐시가 오히려 제거를 빨리 반영시킨다. 예측이 정확히 반대였던 이유다.

유예는 캐시로 만드는 것이 아니라, 옛 키를 JWKS 에 남겨 두는 기간으로 만들어야 한다. 이것이 이 실험의 한 줄이다.

4-6. 그래서 겹침 구간은 얼마나 길어야 하는가

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

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

확인 — 이 값들은 realm 설정이다. 직접 본다. 미검증

sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
  get realms/keycloak-patterns --fields accessTokenLifespan,ssoSessionIdleTimeout,ssoSessionMaxLifespan

이 결과가 의미하는 것 — 「교체하는 동안」의 길이를 정하는 것은 key 가 아니라 그 key 로 만든 것의 수명이다. 30분짜리 refresh token 을 발급하면서 겹침을 5분만 두면 25분어치의 토큰을 죽이는 것이다.

①에 적용하면 — 저장소를 암호화한다면

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

저장된 값에 kid 에 해당하는 표시가 없으면 회전이 불가능하다. 암호화를 설계할 때 key 식별자를 값과 함께 저장해야 하는 이유이고, 그것이 없을 때 어떻게 되는지가 다음 실험(B-7)이다.


5. 복구

5-1. ★ 옛 키는 돌아오지 않는다

이 실험에는 「원상복구」가 없다. 지운 키 공급자는 개인키와 함께 사라졌다. 같은 이름으로 다시 만들면 새 키 쌍이 생기고 kid 가 다르므로, 옛 토큰은 그래도 401 이다.

정상 상태는 「새 키 하나만 남은 상태」다. 4-3 의 출력이 그 상태이고, 실험 전과 다르지만 깨진 상태가 아니다.

5-2. 실험이 남긴 것을 정리한다

남은 것 어떻게
rsa-rotated 공급자 그냥 둔다. 지금 유일한 RS256 서명 키다 지우면 realm 이 서명할 키를 잃는다
셸 변수 OLD NEW CS 터미널을 닫으면 사라진다 unset OLD NEW CS
실험 중 발급한 토큰 60초 뒤 만료된다 별도 조치 없음

이름이 거슬리면 새 공급자를 하나 더 만들고(2-2) rsa-rotated 를 지우면 된다. 다만 그것 역시 또 한 번의 회전이고, 또 하나의 새 키다.

5-3. 원상복구 확인표

항목 명령 돌아왔을 때
서명 키 curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs | tr ',' '\n' | grep kid RS256 이 하나
새 토큰 1-4 의 발급 + 1-5 의 /api/me 200
리소스 서버 sudo kubectl -n header-lab get pods echo1/1 Running
Keycloak sudo kubectl -n keycloak-lab get pods 둘 다 1/1 Running
공급자 목록 kcadm get components --fields id,name,providerId rsa-generated 가 없고 rsa-rotated 가 있다

이 실험이 재지 않은 것 — 겹침 구간을 실제로 30분 유지하며 그 사이에 발급된 refresh token 이 t2 이후 어떻게 되는지는 측정하지 않았다. 재려면 2절과 4절 사이를 30분 이상 벌리고, 그 사이에 받은 refresh token 으로 4절 뒤에 갱신을 시도한다.


막히면

전부 이 실험대가 실제로 겪은 증상이다. 지어낸 것은 없다.

증상 원인 확인
kcadm get components -q type=... 가 빈 결과 -q 필터가 안 먹는다. 오류도 없다 --fields id,name,providerId 로 전체를 받는다 — 1-2
kcadm 이 전부 401/Unauthorized 파드가 재시작되어 kcadm 세션이 사라졌다 config credentials 를 다시 — 1-1
kubectl exec keycloak-0 -- curlexit 127 Keycloak 이미지에 curl 도 wget 도 없다 JWKS·토큰은 kc-lab-1 호스트에서 친다
jq: command not found 이 실험대에는 jq 가 없다 tr ',' '\n' | grep 로 자른다 — 1-3
kid 를 세니 2개인데 문서는 1개라고 한다 RS256 이 아닌 암호화 키가 섞여 있다 tr '}' '\n' | grep -c RS256 또는 kcadm get keys — 1-3
공급자를 추가했는데 새 토큰의 kid 가 그대로 config.priority 가 기존보다 낮다 값이 ["200"] 처럼 배열인지 — 2-2
추가만 했는데 옛 토큰이 401 추가가 아니라 토큰이 만료됐다(60초) 클레임의 expdate +%s 비교 — 3-3
제거했는데 토큰이 401 지운 것이 새 공급자다 kid 를 다시 확인하고 남은 공급자 목록을 본다 — 4-1
제거했는데 옛 토큰이 200 지운 것이 그 토큰의 키가 아니다 토큰 헤더의 kid 와 지운 공급자의 키를 대조 — 4-1
「캐시 때문일 것」이라 재시작을 기다린다 캐시는 유예를 주지 않는다 재시작 전후가 같다 — 4-5
지운 키를 되살리려 한다 되살릴 수 없다. 같은 이름 ≠ 같은 키 5-1

다음

실험 B-6 이 남긴 질문
B-7 cookie secret 같은 모양의 문제인데 kid 가 없다. 겹침 구간을 만들 수 있는가 — 답은 「없다」
D-2 버전 업그레이드 Redis 의 Java 직렬화 세션도 같은 「옛 형식을 읽을 수 있는가」 문제다
설계 저장소를 암호화한다면 값과 함께 key 식별자를 저장해야 회전할 수 있다
전부 빈 출력은 「없다」가 아니다. -q 필터 하나가 조용히 실패했다