diff --git a/docs/evidence/b6-key-rotation/01-before-rotation.txt b/docs/evidence/b6-key-rotation/01-before-rotation.txt new file mode 100644 index 0000000..4542332 --- /dev/null +++ b/docs/evidence/b6-key-rotation/01-before-rotation.txt @@ -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 diff --git a/docs/evidence/b6-key-rotation/02-rotation.txt b/docs/evidence/b6-key-rotation/02-rotation.txt new file mode 100644 index 0000000..37b4dc0 --- /dev/null +++ b/docs/evidence/b6-key-rotation/02-rotation.txt @@ -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 diff --git a/docs/evidence/b6-key-rotation/03-old-key-removed.txt b/docs/evidence/b6-key-rotation/03-old-key-removed.txt new file mode 100644 index 0000000..410504e --- /dev/null +++ b/docs/evidence/b6-key-rotation/03-old-key-removed.txt @@ -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 diff --git a/docs/evidence/b6-key-rotation/README.md b/docs/evidence/b6-key-rotation/README.md new file mode 100644 index 0000000..03b9c5b --- /dev/null +++ b/docs/evidence/b6-key-rotation/README.md @@ -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 를 다시 받으므로 제거가 곧바로 반영된다. 유예는 옛 키를 남겨두는 기간으로 만들어야 한다. diff --git a/docs/experiment-b6-key-rotation.md b/docs/experiment-b6-key-rotation.md new file mode 100644 index 0000000..5698aad --- /dev/null +++ b/docs/experiment-b6-key-rotation.md @@ -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/ -r keycloak-patterns +``` + +--- + +## 7. 다음 실험에 남기는 것 + +| 실험 | 이 실험이 준 것 | +|---|---| +| **B-7** oauth2-proxy cookie secret | **같은 모양의 문제.** 다만 거기는 겹침 구간을 만들 수 있는가가 관건 | +| **D-2** 버전 업그레이드 | Redis 의 **Java 직렬화 세션**(B-1)도 같은 "옛 형식을 읽을 수 있는가" 문제다 | +| 설계 | 암호화를 넣는다면 **값과 함께 key 식별자를 저장**해야 회전할 수 있다 |