---
title: "📓 작업일지 — 앱 OAuth challenge 1회용 잠금 + redeem state 완전일치 검증(Codex 4회차 우회 차단)"
category: "worklog"
parent: "Claude Code 작업일지"
updated: "2026-07-13"
priority: "High"
purpose: "앱(Capacitor) OAuth park/redeem 흐름의 1회용 재사용 방지를 state 전체 기준에서 PKCE challenge 기준으로 바꾸고, redeem 시 앱이 제출한 state가 봉인된 state와 완전히 일치하는지 이중 검증하도록 강화한 보안 수정 기록(demo df79797 선검증분 main 반영)."
read_when: ["앱 OAuth", "native_auth_once", "PKCE challenge", "park redeem", "state 재사용", "Codex 4회차", "청소 분기"]
document_type: "개발일지"
source_status: "generated"
knowledge_group: "03_history"
work_timestamp: "20260713_155925"
source_of_truth: "https://dallog-tools.hansbridge.co.kr/knowledge/"
context: "달록 본레포(ccy-hansbridge/dallog), 커밋 d0227da·9dda1b0(2026-07-13, demo df79797 선검증분 main 반영)."
---

# 작업일지 — 앱 OAuth challenge 1회용 잠금 + redeem state 완전일치 검증

> 이전 작업일지(2026-07-13 park/redeem 도입분)에서는 딥링크 가로채기 재봉인 공격을 막기 위해 park 호출 시 "state" 전체를 1회용으로 잠갔다. 그런데 Codex 4차 재검수에서 "state의 UUID 부분만 바꾸고 나머지(PKCE challenge)는 그대로 두면 같은 challenge로 새로운 state를 만들어 재봉인이 가능하다"는 우회 경로가 지적됐다. 이번 커밋은 잠금 기준을 challenge로 바꾸고, redeem 단계에서 state 전체 일치까지 추가로 검증해 이중으로 막았다.

## 1. 무엇을 했나

### 1회용 잠금 기준을 state → challenge(PKCE)로 변경
- `workers/auth-proxy/index.js`의 `handleNativePark`에서 `markOnceOrReject(env, 'state', state)` 호출을 `markOnceOrReject(env, 'chal', m[1])`(PKCE challenge)로 교체.
- 이유: state 전체를 잠그면 UUID 부분만 바꾼 새 state로 같은 challenge를 다시 park 요청할 때 통과되어 버림(Codex 4회차 치명 지적). challenge는 PKCE verifier와 1:1로 묶여 있어 이 값을 기준으로 잠가야 진짜 재사용을 막을 수 있음.
- 오류 코드도 `state_reused`/`state`에서 `challenge_reused`/`인증요청`으로 갱신.

### redeem 단계에 state 완전일치 검증 추가
- 봉인(seal) 시 payload에 전체 state(`s`)도 함께 저장하도록 확장: `sealNative(env, { a, r, c: m[1], s: state, exp })`.
- `handleNativeRedeem` 요청 바디에 `state` 필드를 필수로 추가(`otc`, `verifier`에 이어 `state`도 없으면 400 `missing_params`).
- 회수 시 `sealed.s !== state`면 403 `state_mismatch`로 거부 — 앱이 제출한 state가 park 시점에 봉인해 둔 state와 정확히 같아야만 통과. challenge 충돌 차단 외에 이중 방어선.

### DB 마이그레이션 갱신
- `migrations/2026-07-13_native_auth_once.sql`의 `kind` 체크 제약을 `('state','otc')` → `('state','otc','chal')`로 확장해 새 잠금 종류(`chal`)를 허용.

### 후속 커밋 — 청소 분기 이동(9dda1b0)
- `markOnceOrReject`의 기회적 청소(1시간 지난 만료 행 삭제) 분기가 여전히 `kind === 'state'` 조건에 걸려 있어, 위 변경으로 실제 park 호출은 `kind='chal'`을 쓰는데 청소 코드는 실행되지 않는 상태였음. 이 조건을 `kind === 'chal'`로 옮겨 park 시점마다 만료 행이 다시 정리되도록 고쳤다(무한 증가 방지 로직 복구).

## 2. 주요 변경 파일

| 파일 | 내용 |
|---|---|
| `migrations/2026-07-13_native_auth_once.sql` | `kind` 체크 제약에 `'chal'` 추가 |
| `workers/auth-proxy/index.js` | park 잠금 기준 state→challenge 변경, redeem에 state 완전일치 검증 추가, 청소 분기를 kind=chal로 이동 |

### 핵심 발췌 — redeem state 완전일치 검증
```diff
-  const { otc, verifier } = body || {}
-  if (!otc || typeof otc !== 'string' || !verifier || typeof verifier !== 'string') {
-    return jsonResponse({ error: { type: 'missing_params', message: 'otc/verifier 누락' } }, 400, origin, reqHeaders)
+  const { otc, verifier, state } = body || {}
+  if (!otc || typeof otc !== 'string' || !verifier || typeof verifier !== 'string' || !state || typeof state !== 'string') {
+    return jsonResponse({ error: { type: 'missing_params', message: 'otc/verifier/state 누락' } }, 400, origin, reqHeaders)
   }
   ...
+  if (sealed.s !== state) {
+    return jsonResponse({ error: { type: 'state_mismatch', message: 'state 불일치' } }, 403, origin, reqHeaders)
+  }
```

## 3. 관련 커밋

- `d0227da` fix(auth): 앱 OAuth challenge 1회용+redeem state 완전일치(Codex 4회차 우회 차단, demo df79797 선검증분) (2026-07-13 15:59)
- `9dda1b0` fix(auth): 청소 분기 kind=chal 이동(demo 선검증분) (2026-07-13 16:21)

## 4. 남은 이슈

커밋 메시지 기준: demo-dallog 커밋 `df79797`에서 먼저 선검증된 뒤 main에 반영된 것으로 명시됨(배포 거버넌스 원칙 준수). 그 외 별도로 언급된 남은 이슈 없음.

