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>
708 lines
32 KiB
Markdown
708 lines
32 KiB
Markdown
# 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: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` 로 떨어진다. **맨 앞에서 한 번 해 둔다.**
|
||
|
||
**하기**
|
||
```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` 필터 하나가 조용히 실패했다 |
|