# 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 로 > 저장된 값은 어떻게 읽는가."* --- ## 구조 ![B-6 구조 — 겹침 구간과 제거 시점](diagrams/b6-key-rotation.svg) > 다이어그램 규약은 [`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/$(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"' | grep '"id"' | cut -d'"' -f4) -r keycloak-patterns ``` --- ## 7. 다음 실험에 남기는 것 | 실험 | 이 실험이 준 것 | |---|---| | **B-7** oauth2-proxy cookie secret | **같은 모양의 문제.** 다만 거기는 겹침 구간을 만들 수 있는가가 관건 | | **D-2** 버전 업그레이드 | Redis 의 **Java 직렬화 세션**(B-1)도 같은 "옛 형식을 읽을 수 있는가" 문제다 | | 설계 | 암호화를 넣는다면 **값과 함께 key 식별자를 저장**해야 회전할 수 있다 |