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>
32 KiB
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가 끝나 있고, realmkeycloak-patterns에 클라이언트bff-confidential과 사용자labuser가 있다.- 리소스 서버(
echo, 네임스페이스header-lab)가 떠 있다. 이 실험의 401/200 은 전부 그 앱이 판정한다. - 명령은
kc-lab-1에서 친다.kubectl은sudo로 쓴다 (kubeconfig 를 사용자 홈에 복사해 뒀다면sudo는 빼도 된다). - Keycloak 이미지에는
curl도wget도 없다(exit 127). 그래서 JWKS 와 토큰은kc-lab-1호스트에서 공개 이름으로 친다.kcadm.sh만 파드 안에서 돈다 — 항상kubectl exec로 감싼다. - 이 실험대에는
jq가 없다. JSON 은tr과grep으로 자른다.
주의 — 이건 되돌릴 수 없는 실험이다
서명 키 공급자를 실제로 지운다. 지운 키는 돌아오지 않는다.
같은 이름으로 공급자를 다시 만들어도 새 키 쌍이 생기고 kid 가 다르다.
그러니 옛 키로 서명된 토큰은 영구히 검증되지 않는다.
실험대에서만 한다. 전 구간 약 15분이고, 3절까지는 아무것도 안 깨진다. 파괴가 시작되는 지점은 4. 관찰 이며, 그 앞에 경고를 다시 붙여 두었다.
표시 규약
| 표시 | 뜻 |
|---|---|
| 실측 | 2026-09-04 14:30–14:32 KST 수집 기록의 출력 원문. 증거 파일에 그대로 있다 |
| 형태 | 값이 매번 달라지는 출력. 모양만 보이고 값은 당신 것과 다르다 |
| 미검증 | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행 기록에 이 명령의 출력은 없다 |
해설 문서 머리에 적힌
15:50–16:00 KST는 문서를 쓴 시각이고, 증거 파일의 mtime 은14:30–14: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 배열이 여러 줄로 나온다. providerId 가 rsa-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
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
어디를 봐야 하는가 — 키마다 붙는 algorithm 과 status.
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"}
발급 토큰의 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
=== [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"'
원래 실행에서 지운 것은 이것이다.
=== [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
=== [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
=== [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
=== [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 |
echo 가 1/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 -- curl 이 exit 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초) | 클레임의 exp 와 date +%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 필터 하나가 조용히 실패했다 |