An independent audit found ten documents printing values their evidence files do not contain. C-1 printed a session count of 0 where the evidence says 4, C-2 printed a success readback for a command that exited 1, and A-1 credited the conntrack flush with a split that the timestamps attribute to a pod restart four seconds earlier. Also measured wal_writer_delay, which A-3 had asserted as matching without ever querying it, relabelled the A-6 control that moved 41 percent, noted A-8's nine-sample resolution, corrected D-1's RTO to the 41 seconds its own timeline shows, and added a correction banner to D-2. Every experiment document now links its evidence files with their real collection times, and the duplicate screenshots are documented as duplicates. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
252 lines
8.4 KiB
Markdown
252 lines
8.4 KiB
Markdown
# B-6 — key 를 교체하면 옛 것으로 만든 것은 어떻게 되는가 → Q3 미지수 3
|
||
|
||
브랜치 `feature/keycloak-b6-key-rotation` ·
|
||
증거 [`docs/evidence/b6-key-rotation/`](evidence/b6-key-rotation/) ·
|
||
2026-09-04 15:50–16:00 KST
|
||
|
||
선행: [`B-2`](experiment-b2-multi-instance-session.md) — 토큰이 **평문**임을 확인했다
|
||
|
||
**대응 질문** — Q3 미지수 3
|
||
> *"암호화 key 를 어디에 두고 어떻게 교체하게 되는가. 교체하는 동안 이전 key 로
|
||
> 저장된 값은 어떻게 읽는가."*
|
||
|
||
---
|
||
|
||
## 구조
|
||
|
||

|
||
|
||
> 다이어그램 규약은 [`diagrams/_style.md`](diagrams/_style.md).
|
||
> 실험대 전체 구조는 [`diagrams/lab-topology.svg`](diagrams/lab-topology.svg).
|
||
|
||
---
|
||
|
||
## 0. 결론부터
|
||
|
||
**질문이 두 갈래로 나뉜다.**
|
||
|
||
| | 상태 |
|
||
|---|---|
|
||
| **① 토큰 저장소의 암호화 key** | **★ 존재하지 않는다.** B-2 에서 평문임을 확인했다 |
|
||
| **② 토큰 서명 key (Keycloak realm)** | 존재하고 회전 가능하다 — **측정했다** |
|
||
|
||
②를 측정한 결과가 ①을 설계할 때 그대로 쓰인다.
|
||
|
||
```
|
||
키 추가 → 무중단. JWKS 에 옛 키와 새 키가 함께 남는다
|
||
키 제거 → ★ 즉시 파괴적. 옛 키로 서명된 토큰이 곧바로 401
|
||
```
|
||
|
||
**"교체하는 동안 이전 key 로 저장된 값은 어떻게 읽는가" 의 답은
|
||
"두 key 를 동시에 들고 있으면 읽힌다" 이고, 위험한 것은 교체가 아니라
|
||
옛 key 를 언제 버리느냐다.**
|
||
|
||
---
|
||
|
||
## 1. ① 암호화 key 는 애초에 없다
|
||
|
||
B-2 에서 확인했다.
|
||
|
||
```sql
|
||
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
|
||
```
|
||
|
||
### 회전 — 우선순위가 높은 공급자를 추가한다
|
||
|
||
```bash
|
||
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. 옛 키를 제거하면 — 여기가 진짜 질문이다
|
||
|
||
```bash
|
||
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
|
||
|
||
```json
|
||
{"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` 가 조용히 빈 결과
|
||
|
||
```bash
|
||
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`](evidence/b6-key-rotation/01-before-rotation.txt) | 터미널 원문 |
|
||
| [`02-rotation.txt`](evidence/b6-key-rotation/02-rotation.txt) | 터미널 원문 |
|
||
| [`03-old-key-removed.txt`](evidence/b6-key-rotation/03-old-key-removed.txt) | 터미널 원문 |
|
||
|
||
파일별 상세는 [`evidence/b6-key-rotation/README.md`](evidence/b6-key-rotation/README.md).
|
||
|
||
## 6. 재현 절차 (명령어)
|
||
|
||
```bash
|
||
# 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/<old-id> -r keycloak-patterns
|
||
```
|
||
|
||
---
|
||
|
||
## 7. 다음 실험에 남기는 것
|
||
|
||
| 실험 | 이 실험이 준 것 |
|
||
|---|---|
|
||
| **B-7** oauth2-proxy cookie secret | **같은 모양의 문제.** 다만 거기는 겹침 구간을 만들 수 있는가가 관건 |
|
||
| **D-2** 버전 업그레이드 | Redis 의 **Java 직렬화 세션**(B-1)도 같은 "옛 형식을 읽을 수 있는가" 문제다 |
|
||
| 설계 | 암호화를 넣는다면 **값과 함께 key 식별자를 저장**해야 회전할 수 있다 |
|