Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa2c3907f5 |
@@ -0,0 +1,9 @@
|
||||
=== [1] 회전 전: 토큰 발급 + JWKS 상태 ===
|
||||
발급 토큰의 kid: OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM
|
||||
JWKS 의 RS256 키 수: 1
|
||||
JWKS kid 목록:
|
||||
{"keys":[{"kid":"gokjn0zFUok8r7JVqW1cxuyojH1bTT87vzfQG9RrFX4"
|
||||
{"kid":"OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM"
|
||||
|
||||
=== [2] 그 토큰이 지금 통하는가 (리소스 서버) ===
|
||||
/api/me HTTP 200
|
||||
@@ -0,0 +1,16 @@
|
||||
=== [3] 키 회전 — 우선순위가 더 높은 RSA 공급자를 추가한다 ===
|
||||
Created new component with id '7902af43-a0cc-4ebd-ad25-04d563854d16'
|
||||
|
||||
=== [4] 회전 후 JWKS — 옛 키가 남아 있는가 ===
|
||||
RS256 키 수: 2
|
||||
kid 목록:
|
||||
{"keys":[{"kid":"1B4AQHoxZvFaQi1tc1byz8ifU-nYFB6engD4YB4Fz84"
|
||||
{"kid":"gokjn0zFUok8r7JVqW1cxuyojH1bTT87vzfQG9RrFX4"
|
||||
{"kid":"OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM"
|
||||
|
||||
=== [5] 새 토큰은 어느 키로 서명되는가 ===
|
||||
새 토큰의 kid: 1B4AQHoxZvFaQi1tc1byz8ifU-nYFB6engD4YB4Fz84
|
||||
|
||||
=== [6] ★ 회전 전에 발급된 토큰은 아직 통하는가 ===
|
||||
옛 토큰 /api/me HTTP 200
|
||||
새 토큰 /api/me HTTP 200
|
||||
@@ -0,0 +1,16 @@
|
||||
=== [7] 옛 RSA 공급자(980ee9b7 = OY-caYDN 키) 제거 ===
|
||||
제거 완료
|
||||
|
||||
=== [8] JWKS 에서 사라졌는가 ===
|
||||
RS256 키 수: 1
|
||||
{"keys":[{"kid":"1B4AQHoxZvFaQi1tc1byz8ifU-nYFB6engD4YB4Fz84"
|
||||
{"kid":"gokjn0zFUok8r7JVqW1cxuyojH1bTT87vzfQG9RrFX4"
|
||||
|
||||
=== [9] ★ 옛 키로 서명된 토큰은 이제 어떻게 되는가 ===
|
||||
옛 토큰 /api/me HTTP 401 (캐시가 살아 있으면 아직 통할 수 있다)
|
||||
새 토큰 /api/me HTTP 200
|
||||
|
||||
=== [10] 리소스 서버를 재시작해 JWKS 캐시를 비우면 ===
|
||||
deployment "echo" successfully rolled out
|
||||
옛 토큰 /api/me HTTP 401
|
||||
새 토큰 /api/me HTTP 200
|
||||
@@ -0,0 +1,16 @@
|
||||
# B-6 — key 회전 증거
|
||||
|
||||
2026-09-04 15:50–16:00 KST
|
||||
해설: [`docs/experiment-b6-key-rotation.md`](../../experiment-b6-key-rotation.md)
|
||||
|
||||
| 파일 | 무엇을 보여주는가 |
|
||||
|---|---|
|
||||
| `01-before-rotation.txt` | 회전 전 — 토큰 `kid=OY-caYDN...`, JWKS RS256 1개, `/api/me` 200 |
|
||||
| `02-rotation.txt` | 우선순위 200 공급자 추가 → **JWKS RS256 2개**, 새 토큰은 새 kid, **옛 토큰도 새 토큰도 200** (무중단) |
|
||||
| `03-old-key-removed.txt` | 옛 공급자 제거 → JWKS 1개, **옛 토큰 즉시 401**. 리소스 서버 재시작 후에도 동일 |
|
||||
|
||||
## 핵심 세 줄
|
||||
|
||||
1. **Keycloak 의 키 회전은 "바꾸기"가 아니라 "더 높은 우선순위로 추가하기"** 다. 추가만으로는 아무것도 안 깨진다.
|
||||
2. **위험한 것은 옛 키를 버리는 시점이다.** 제거 즉시 그 키로 서명된 토큰이 401 이 된다.
|
||||
3. **캐시는 유예가 아니다.** 모르는 `kid` 를 만나면 JWKS 를 다시 받으므로 제거가 곧바로 반영된다. 유예는 옛 키를 남겨두는 기간으로 만들어야 한다.
|
||||
@@ -0,0 +1,228 @@
|
||||
# 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 로
|
||||
> 저장된 값은 어떻게 읽는가."*
|
||||
|
||||
---
|
||||
|
||||
## 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층에서 반복해 만난 "조용한 실패"다.
|
||||
|
||||
---
|
||||
|
||||
## 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 식별자를 저장**해야 회전할 수 있다 |
|
||||
Reference in New Issue
Block a user