바꿀 수 없는 키는 지킬 수 없는 키다
한 줄 요약
서명 키는 언젠가 바꿉니다. 그러려면 서명에 키 ID(kid)를 실어 어떤 키로 확인할지 알게 하고, 새 키를 올린 뒤에도 옛 키를 정해 둔 겹치는 기간 동안은 받아 주고, 그 기간이 끝나면 거절하고 키 묶음에서 뺍니다. 키는 소스가 아니라 키 묶음 파일에서 읽습니다.
왜 이 요구가 필요한가
서명된 내려받기 링크, 웹훅 서명, 이메일 확인 링크는 모두 HMAC 같은 서명 키 하나에 기댑니다. 키가 소스에 박혀 있고 키가 하나뿐이면 두 가지 문제가 생깁니다. 첫째, 소스를 볼 수 있는 모든 사람(과거의 협력사, 저장소의 사본, 빌드 결과물)이 키를 갖습니다. 둘째, 키를 바꾸는 순간 이미 나간 링크가 모두 죽습니다. 그래서 아무도 키를 바꾸지 않고, 키를 바꿀 수 없다는 것은 키가 새었을 때 할 수 있는 일이 없다는 뜻입니다.
OWASP Key Management Cheat Sheet 는 키마다 쓰는 기간(cryptoperiod)을 정하고 회전 일정을 문서로 두라고 적습니다. 쓰는 기간이 짧을수록 키 하나가 지키는 데이터의 양이 줄어듭니다. 그리고 키를 소스 저장소나 빌드 결과물에 넣지 말라고 분명히 적습니다.
어떻게 지키나
키 묶음은 이런 모양입니다.
{"active": "k2026b",
"keys": {"k2026a": {"secret": "<base64>", "retire_at": 1767225600},
"k2026b": {"secret": "<base64>", "retire_at": null}}}
서명은 언제나 활성 키로 하고, 서명할 글에 경로와 만료 시각과 kid 를 함께 넣습니다. 확인할 때는 링크의 kid 로 키를 고릅니다. 모르는 kid 를 활성 키로 대신 확인하면 안 됩니다. 그러면 kid 가 아무 의미 없는 장식이 되고, 은퇴한 키로 만든 링크가 kid 만 바꿔 다시 살아날 수 있습니다.
확인 순서도 정합니다. 모양 → 키 → 은퇴 → 서명 → 만료입니다. 만료 시각(exp)은 서명을 확인한 뒤에야 믿습니다. 서명을 확인하기 전의 exp 는 누구나 고쳐 쓸 수 있는 숫자일 뿐입니다. 서명 비교는 hmac.compare_digest 로 합니다. 서명은 공격하는 쪽이 바이트를 바꿔 가며 보낼 수 있는 값이라, 비밀번호 해시와 달리 시간 차이가 실제로 단서가 됩니다.
키 자체는 secrets.token_bytes(32) 처럼 충분히 긴 무작위 바이트로 만들고, 사람이 고른 문장이나 서비스 이름을 키로 쓰지 않습니다. HMAC 은 키가 비밀일 때만 의미가 있고, 짧거나 추측할 수 있는 키는 서명 전체를 무의미하게 만듭니다.
회전은 세 단계입니다. 새 키를 넣고 활성으로 올립니다(rotate). 옛 키에는 retire_at = 지금 + 겹치는 기간 을 적고 지우지 않습니다. 그 기간 동안 이미 나간 링크가 살아 있습니다. 기간이 지나면 확인이 그 키를 거절하고, prune 이 키 묶음에서 뺍니다. 겹치는 기간은 링크가 살 수 있는 가장 긴 시간보다 길게 잡아야 합니다. 짧으면 아직 만료 전인 링크가 먼저 죽습니다.
시험으로 적을 때는 시간을 숫자로 넘깁니다. verify_link(keyring, link, now) 처럼 지금 시각을 인자로 받으면, 회전 직후(겹치는 기간 안)와 은퇴 시각 바로 그때를 시험 하나에서 차례로 확인할 수 있습니다. 「옛 링크는 now=2000 에 살고 now=4600 에 죽는다」 같은 문장이 그대로 시험이 됩니다. 회전 함수가 이미 있는 kid 를 거절하는지도 시험해 둡니다. 같은 이름으로 다른 키를 덮어쓰면, 그 이름으로 서명된 옛 링크가 아무 기록도 없이 모두 죽습니다.
현장에서 만나는 모습
키가 새었을 때의 회전은 계획된 회전과 다릅니다. 이때는 겹치는 기간을 0 으로 두고 옛 키를 바로 은퇴시켜, 이미 나간 링크를 일부러 모두 죽입니다. 평소에 회전 절차를 돌려 두어야 사고 때 같은 절차를 겹치는 기간만 바꿔 바로 쓸 수 있습니다. 회전을 한 번도 해 보지 않은 시스템은 사고 날에 처음 해 보게 됩니다.
운영에서는 키 묶음을 키 관리 서비스(KMS)나 비밀값 저장소에 두고, 서비스는 시작할 때와 주기적으로 읽습니다. 이 모듈은 그 앞 단계인 '키 묶음을 다루는 코드' 를 다룹니다. JWT 의 kid 처리와 알고리즘 혼동, 리프레시 토큰 회전은 '세션과 토큰' 코스에서 이어집니다. 감사 로그에는 회전과 정리와 거절 이유를 남기고, 키와 서명은 남기지 않습니다.
English corner
- 「key rotation」 키를 정기적으로 바꾸는 일입니다. OWASP 는 "Prefer automated key rotation" 이라고 적습니다.
- 「cryptoperiod」 키 하나를 쓰는 기간입니다. 위험 평가로 정하라는 것이 같은 문서의 권고입니다.
- 「key ID (kid)」 어떤 키로 서명했는지 알려 주는 이름표입니다. 이름표는 키를 고르는 데만 쓰고, 모르는 이름표는 거절합니다.
다음 실습에서 할 것
서명 링크가 소스의 키 하나로 서명되고 == 로 비교되며, 회전과 정리 함수는 아무 일도 하지 않습니다. 요구 A(kid 를 실어 그 키로 확인)와 요구 B(겹치는 기간 동안 옛 키를 받아 주고 그 뒤에 은퇴)를 시험으로 쓰고 고친 뒤, 회전과 거절을 감사 로그에 남깁니다.