노출된 실습 키의 교체
75분 안팎
학습 목표
삭제와 자격 증명 폐기의 차이를 확인합니다.
개념
왜 파일 삭제로 사고 대응이 끝나지 않나요
더미 키가 화면 공유나 로그에 노출되었다고 가정합니다. 소스의 줄을 지우는 것은 앞으로 그 경로에서 읽지 못하게 하는 조치입니다. 이미 얻은 키나 세션을 가진 사람의 접근 능력은 그대로일 수 있습니다. 교체는 새 값을 쓰게 하는 일이며 폐기는 옛 값이나 옛 값에서 만들어진 접근 수단을 거절하는 일입니다. 두 결과를 각각 확인해야 노출에 대한 대응을 완료했다고 말할 수 있습니다.
이번 앱의 세션은 서버 메모리에 저장한 불투명 ID입니다. JWT를 검증하거나 자료를 암호화하는 구조가 아닙니다. 기존 m06의 로그인·만료·로그아웃 모델을 유지하고, 무작위 nonce를 키 기반 HMAC으로 처리하여 같은 64자 ID 형식으로 발급합니다. HMAC은 키가 있는 메시지 인증 연산이며 암호화가 아닙니다. 사용자 자료를 ID 안에 넣지 않고 세션 표의 사용자와 만료 시각으로 로그인 상태를 확인합니다.
무엇을 교체하고 무엇을 폐기하나요
createSessions는 현재 키와 세션 Map을 닫힌 함수 범위에 보관합니다. issue는 새로운 ID를 만들고 사용자 객체의 복사본과 만료 시각을 기록합니다. resolve는 Map에 존재하고 만료되지 않은 ID만 사용자로 해석합니다. 키를 바꾸어도 Map의 옛 ID가 남아 있으면 resolve는 여전히 로그인한 사용자라고 판단합니다. 키 교체가 세션 조회에 자동으로 적용된다고 가정하지 않는 것이 이번 핵심입니다.
rotate는 새 키 형식을 검증하고 현재 키와 다른지 확인한 뒤 현재 키를 교체하고 모든 세션을 지웁니다. 검증 실패 전에 상태를 지우면 오타 하나가 정상 사용자들을 강제로 로그아웃시킬 수 있습니다. 키가 같으면 교체로 처리하지 않고 rotation: key must change 오류를 냅니다. 잘못된 교체 요청 뒤 옛 정상 세션이 유지되는 대조도 필요합니다. 정상 교체 뒤에는 alice와 bob의 세션이 모두 거절되어야 합니다.
rotate(next) {
loadConfig({VAULT_KEY:next});
if(next===current)throw new Error('rotation: key must change');
current=next;
sessions.clear();
}
이 순서는 단일 프로세스 메모리 모델의 계약입니다. 여러 서버의 세션 저장소가 있으면 각 서버가 같은 키 세대와 폐기 상태를 적용했는지 따로 확인해야 합니다. 이 과제는 분산 교체의 원자성이나 비밀 저장소의 자동 회전을 구현하지 않습니다. 모든 기존 세션을 폐기하는 정책은 사용자에게 재로그인을 요구하므로, 영향·안내·복구 담당자를 실제 변경 계획에 포함합니다.
어떻게 앱의 CSRF 상태와 연결하나요
secure-app.cjs는 인증 상태와 별도로 세션별 CSRF 토큰을 보관합니다. auth의 키 교체가 성공한 다음 tokens.clear를 호출합니다. 인증 세션만 지우면 더 이상 사용되지 않는 CSRF 상태가 메모리에 남습니다. 반대로 잘못된 키에서 CSRF 상태만 지우면 옛 세션은 로그인 상태인데 정상 변경이 갑자기 실패합니다. handle.rotateKey는 성공한 인증 교체 뒤 보조 상태를 지우는 관리 함수입니다.
이 함수는 HTTP 요청에서 호출되지 않습니다. 누구나 키 교체를 요청하는 관리 경로를 새로 만드는 과제가 아닙니다. 학습자는 시험 코드에서 관리 권한이 있는 호출을 가정합니다. 공개 서비스에서는 변경 승인과 호출자 인증·인가·감사 기록을 별도로 설계해야 합니다. 기존 /login의 Origin 검사, 소유자 제한, 입력 한도와 SQLite 저장 경로를 그대로 유지하여 교체 작업이 다른 보안 통제를 우회하지 않게 합니다.
수정 전후에 같은 자료로 비교합니다
합성 alice로 로그인하여 자료 하나를 만든 뒤 옛 세션과 CSRF를 보관합니다. 잘못된 새 키를 보내면 교체는 거절되고 같은 세션의 조회는 200입니다. 올바른 다른 키로 교체한 뒤 옛 세션의 조회는 401이어야 합니다. 옛 세션으로 수정도 거절합니다. 새 로그인은 성공하고 같은 자료의 본문은 남아 있어야 합니다. 인증 접근을 폐기한다고 자료나 DB를 삭제하면 안 됩니다.
새 로그인에 옛 CSRF를 붙인 수정은 403이며, 새 세션에서 받은 CSRF를 붙인 동일한 수정은 200입니다. 따라서 옛 접근 수단 거절과 새 정상 업무 성공을 함께 입증합니다. 로컬 단위 과제는 만료 시각과 정확히 같은 순간의 거절, 로그아웃 이후 거절도 검사합니다. 키 교체를 구현하며 기존 만료와 로그아웃 계약을 깨지 않았는지 확인하는 회귀입니다.
오류 메시지는 어떤 결함을 알려 주나요
옛 세션의 기대값 null 대신 사용자 객체가 보이면 Map의 이전 상태가 남았거나 교체 함수가 실제 인증 객체에 연결되지 않은 것입니다. 새 로그인이 실패하면 키 갱신·ID 생성·세션 저장 경로를 대조합니다. 자료 본문 비교가 실패하면 인증 폐기와 자료 삭제를 혼동했는지 확인합니다. 403 대신 200이 나오면 새 세션에서 옛 CSRF를 허용하고 있는지 살펴봅니다. 원문 키를 출력하는 방법으로 조사하지 않습니다.
무작위로 생성한 ID 전체는 실행마다 달라지므로 출력 계약은 값 자체가 아닌 상태와 판정 결과를 사용합니다. 테스트는 old와 next가 다르고 두 세션의 사용 가능 여부가 바뀌는지를 확인합니다. 반복 문자 키는 비교 가능한 고정 fixture로만 사용합니다. 운영 키가 유출되어도 nonce를 알아야 한다는 이유로 방치하지 않습니다. 이 교재의 제한된 세션 모델을 다른 토큰 체계의 위험 평가로 확대하지 않습니다.
복구와 보고 기록
실제 노출 대응 기록에는 발견 시각·노출 경로·대상 키 식별자·영향받는 기능·폐기 범위·새 정상 접근 확인을 넣고 키 원문은 쓰지 않습니다. 키 식별자는 비밀값과 별도로 관리한 버전 이름이면 충분합니다. 로그 사본 정리는 접근 제한과 보관 정책에 따라 진행하되, 기존 로그의 조사 가치와 사본 범위를 확인합니다. 이번 미션 E07은 가상 노출 조건과 함수 호출 검증임을 명시합니다.
새 설정에 오타가 있어 장애가 생겼다고 노출된 옛 키로 복구하면 접근 폐기 목표를 되돌립니다. 안전한 새 키의 설정을 고치고 재로그인 경로를 복구하는 방향을 선택합니다. 암호화 키였다면 기존 자료 복호화와 키 재암호화가 별도 과제가 되지만 이번 자료는 SQLite 평문이므로 해당 결과를 주장하지 않습니다. 더 읽기의 세션 보안 장과 비교하면서 인증 키·세션·CSRF·자료 수명의 차이를 설명합니다.
관련 원칙과 도구의 범위는 Node.js crypto 문서에서 확인할 수 있습니다. 이 레슨의 판정은 제공된 교재 코드와 고정 fixture 범위에 한정합니다.
따라하기
옛 접근과 새 접근을 비교합니다
무작위 세션 ID 원문은 출력하지 않고 조회 가능 여부만 확인합니다.
const {createHmac,randomBytes}=require('node:crypto');
function sessions(){let key='a'.repeat(64);const table=new Map();return {
issue(){const id=createHmac('sha256',Buffer.from(key,'hex')).update(randomBytes(32)).digest('hex');table.set(id,'demo-alice');return id;},
resolve(id){return table.get(id)||null;},
rotate(next){if(!/^[0-9a-f]{64}$/.test(next))throw new Error('invalid key');if(next===key)throw new Error('same key');key=next;table.clear();}
};}
const s=sessions(),old=s.issue();console.log(s.resolve(old)!==null);s.rotate('b'.repeat(64));console.log(s.resolve(old)===null);console.log(s.resolve(s.issue())!==null);실행 결과
true true true
잘못된 새 키가 기존 상태를 보존하는지 확인합니다
검증 실패는 교체 성공이 아닙니다.
const {createHmac,randomBytes}=require('node:crypto');
function sessions(){let key='a'.repeat(64);const table=new Map();return {
issue(){const id=createHmac('sha256',Buffer.from(key,'hex')).update(randomBytes(32)).digest('hex');table.set(id,'demo-alice');return id;},
resolve(id){return table.get(id)||null;},
rotate(next){if(!/^[0-9a-f]{64}$/.test(next))throw new Error('invalid key');if(next===key)throw new Error('same key');key=next;table.clear();}
};}
const s=sessions(),id=s.issue();try{s.rotate('');}catch(e){console.log(e.message);}console.log(s.resolve(id)!==null);실행 결과
invalid key true
같은 값을 교체로 인정하지 않습니다
교체하지 않았는데 완료됐다는 기록을 남기지 않습니다.
const {createHmac,randomBytes}=require('node:crypto');
function sessions(){let key='a'.repeat(64);const table=new Map();return {
issue(){const id=createHmac('sha256',Buffer.from(key,'hex')).update(randomBytes(32)).digest('hex');table.set(id,'demo-alice');return id;},
resolve(id){return table.get(id)||null;},
rotate(next){if(!/^[0-9a-f]{64}$/.test(next))throw new Error('invalid key');if(next===key)throw new Error('same key');key=next;table.clear();}
};}
const s=sessions();try{s.rotate('a'.repeat(64));}catch(e){console.log(e.message);}실행 결과
same key
세션 폐기 TODO를 완성합니다
security-key-rotation에서 sessions.cjs의 rotate를 고치고 bash check.sh를 실행합니다. 정상 교체 뒤 두 계정의 옛 세션이 거절되고 새 로그인·만료 경계·로그아웃이 통과해야 합니다. 누적 미션의 secrets-test.cjs에서는 SQLite 자료 보존과 새 세션의 옛 CSRF 403도 확인합니다.
확인 문제
실습
sessions.cjs의 rotate를 완성합니다. 새 키 검증과 동일 키 거절 후 모든 이전 세션을 폐기합니다. 잘못된 교체는 기존 정상 상태를 보존하고 새 발급·만료 경계·로그아웃도 유지합니다. 테스트를 변경하지 않습니다.
실행 명령
bash check.sh
기대 결과
PASS: invalid rotation preserves state; old sessions rejected; new login, expiry and logout checked
모범 답안
모범 답안 내려받기더 읽기
면접 질문
- 비밀값을 코드와 로그에서 보호하는 방법을 설명합니다.