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

708 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# B-6 재현 가이드 — 서명 키를 회전하고, 옛 키를 버리는 순간을 직접 본다
해설 문서: [`docs/experiment-b6-key-rotation.md`](../../experiment-b6-key-rotation.md) ·
증거 원문: [`docs/evidence/b6-key-rotation/`](../../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`](../05-keycloak/) 가 끝나 있고, realm `keycloak-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. 관찰](#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` 로 떨어진다. **맨 앞에서 한 번 해 둔다.**
**하기**
```bash
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)"
```
**어디를 봐야 하는가****아무것도 안 나오면 성공이다.** 실패하면 한 줄 오류가 뜬다.
> **비밀번호를 화면에 찍지 않는다.** 명령 치환으로 넘기므로 값은 터미널에도
> 셸 히스토리에도 남지 않는다. 존재만 확인하고 싶으면 길이만 본다.
> ```bash
> sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
> -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c
> ```
## 1-2. 지금 어떤 키 공급자가 있나
**확인** — 통째로 받아서 눈으로 본다
```bash
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절에서 지울 대상이 이것이다.** 지금 적어 둔다.
### ★ 여기서 조용한 실패를 하나 만난다
「키 공급자만 걸러 보자」는 자연스러운 시도가 **빈 결과**를 준다.
**미검증** — 원래 실행에서 이렇게 쳤고 아무것도 안 나왔다
```bash
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 원문을 한 번 통째로 본다
**확인**
```bash
curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs
```
**줄바꿈 없이 한 줄로 길게 나온다. 그래도 처음 한 번은 그대로 본다.**
어떤 필드가 들어 있는지 알아야 다음부터 무엇으로 걸러야 할지 안다.
**실측** — 첫머리.
[`01-before-rotation.txt`](../../evidence/b6-key-rotation/01-before-rotation.txt)
에 남은 조각 그대로다
```
{"keys":[{"kid":"gokjn0zFUok8r7JVqW1cxuyojH1bTT87vzfQG9RrFX4"
```
그 뒤로 `kty`·`alg`·`use`·`n`·`e` 가 이어지고 다음 키가 온다.
**`kid` 마다 `alg` 가 따로 붙는다** — 이 사실이 바로 아래에서 쓰인다.
읽을 만하게 자른다. `jq` 가 없으므로 `tr` 로 쉼표를 줄바꿈으로 바꾼다.
**확인**
```bash
curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \
| tr ',' '\n' | grep kid
```
**실측** — [`01-before-rotation.txt`](../../evidence/b6-key-rotation/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 '}'` 로 자르면 한 줄이 한 키가 된다. **미검증**
```bash
curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \
| tr '}' '\n' | grep -c RS256
```
Keycloak 자신에게 묻는 편이 더 확실하다 — **이쪽이 1순위 도구다. 미검증**
```bash
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 에서 꺼내 쓴다
```bash
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` 가 있다
```bash
echo "$OLD" | cut -d. -f1 | tr '_-' '/+' | base64 -d 2>/dev/null; echo
```
**형태**
```json
{"alg":"RS256","typ":"JWT","kid":"OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM"}
```
**실측** — [`01-before-rotation.txt`](../../evidence/b6-key-rotation/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 은 아무 의미가 없다.** 「원래 안 됐던 것」과
「내가 깨뜨린 것」을 구별할 수단이 이것뿐이다.
먼저 응답을 통째로 한 번 본다.
**확인**
```bash
curl -s -i -H "Authorization: Bearer $OLD" https://app1.hyeonworks.com/api/me
```
**어디를 봐야 하는가** — 상태줄과 본문. 200 이면 `subject` 같은 클레임이 돌아온다.
401 이면 `WWW-Authenticate` 헤더에 이유가 붙는다. **이 헤더를 한 번 봐 두면
뒤에서 401 이 났을 때 「왜」를 묻는 자리가 생긴다.**
이제부터는 여러 번 비교해야 하므로 코드만 뽑는다.
**확인**
```bash
curl -s -o /dev/null -w 'old %{http_code}\n' \
-H "Authorization: Bearer $OLD" https://app1.hyeonworks.com/api/me
```
**실측** — [`01-before-rotation.txt`](../../evidence/b6-key-rotation/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` 이었다
```bash
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. 추가한다
**하기**
```bash
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`](../../evidence/b6-key-rotation/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 과 **똑같은 명령**을 다시 친다
```bash
curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \
| tr ',' '\n' | grep kid
```
**실측** — [`02-rotation.txt`](../../evidence/b6-key-rotation/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` 은 건드리지 않는다**
```bash
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`](../../evidence/b6-key-rotation/02-rotation.txt)
```
=== [5] 새 토큰은 어느 키로 서명되는가 ===
새 토큰의 kid: 1B4AQHoxZvFaQi1tc1byz8ifU-nYFB6engD4YB4Fz84
```
**어디를 봐야 하는가**`kid` 가 **우선순위 200 짜리 새 키**로 바뀌었다.
**이 결과가 의미하는 것** — 발급은 우선순위가 가장 높은 키로 간다.
**여기서 kid 가 안 바뀌었다면 priority 를 낮게 준 것이다.** 2-2 로 돌아간다.
## 3-3. ★ 둘 다 통하는가 — 무중단 구간의 실측
**확인**
```bash
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`](../../evidence/b6-key-rotation/02-rotation.txt)
```
=== [6] ★ 회전 전에 발급된 토큰은 아직 통하는가 ===
옛 토큰 /api/me HTTP 200
새 토큰 /api/me HTTP 200
```
**어디를 봐야 하는가****둘 다 200.**
**이 결과가 의미하는 것****키 추가는 무중단이다.** 새 토큰은 새 키로 서명되고,
옛 토큰은 **JWKS 에 아직 있는 옛 키로 검증된다.** 사용자는 아무것도 못 느낀다.
> **여기서 `old` 가 401 이면 두 가지 중 하나다.**
> ① 토큰이 만료됐다(60초). ② 뭔가 다른 것을 건드렸다.
> **가르는 법** — 옛 토큰의 `exp` 를 본다. JWT 의 **가운데 토막**이 클레임이다.
> ```bash
> 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` 는 여전히 안 먹는다
```bash
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"` 보다 **위**에 있다.
```bash
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`](../../evidence/b6-key-rotation/03-old-key-removed.txt)
```
=== [7] 옛 RSA 공급자(980ee9b7 = OY-caYDN 키) 제거 ===
제거 완료
```
`980ee9b7…` 로 시작하는 것이 옛 공급자의 id 이고, 그것이 `OY-caYDN…` 키를
갖고 있었다. **당신 환경의 id 는 다르다.** 증거에 남은 것도 앞 8자뿐이니
**전체 id 는 위 명령의 출력에서 그대로 옮겨 온다.**
## 4-2. 지운다
**하기** — 위 출력에서 고른 id 를 변수에 넣고 지운다
```bash
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 에서 사라졌는가
**확인** — 또 같은 명령이다
```bash
curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \
| tr ',' '\n' | grep kid
```
**실측** — [`03-old-key-removed.txt`](../../evidence/b6-key-rotation/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 과 **똑같은 두 줄**
```bash
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`](../../evidence/b6-key-rotation/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 인가?」
라는 의심이 남는다. **캐시를 비워 보면 갈린다.**
**하기**
```bash
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`](../../evidence/b6-key-rotation/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 설정이다. 직접 본다. **미검증**
```bash
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](../../experiment-b7-cookie-secret-rotation.md) cookie secret | **같은 모양의 문제인데 `kid` 가 없다.** 겹침 구간을 만들 수 있는가 — 답은 「없다」 |
| [D-2](../../experiment-d2-version-upgrade.md) 버전 업그레이드 | Redis 의 Java 직렬화 세션도 같은 **「옛 형식을 읽을 수 있는가」** 문제다 |
| 설계 | 저장소를 암호화한다면 **값과 함께 key 식별자를 저장**해야 회전할 수 있다 |
| 전부 | **빈 출력은 「없다」가 아니다.** `-q` 필터 하나가 조용히 실패했다 |