Skip to content

fix(codex): verify auth.json identity, not just content, before publishing - #3199

Merged
lidge-jun merged 1 commit into
devfrom
codex/2999-publication-identity
Sep 1, 2026
Merged

fix(codex): verify auth.json identity, not just content, before publishing#3199
lidge-jun merged 1 commit into
devfrom
codex/2999-publication-identity

Conversation

@lidge-jun

Copy link
Copy Markdown
Owner

Summary

#2999 named two credential-safety races in native-main refresh. #3112 (fecb77a9) closed the coordination half — refresh now serializes on the canonical CODEX_HOME claim. This is the publication half, which that PR explicitly did not cover.

persistRefreshedMainAuthJson hashed auth.json and re-checked the hash in two hooks before renaming its staged file over it. Both guards asked whether the content matched. Neither asked whether it was still the same file.

That is the weaker question to ask at this boundary, because src/config/atomic-write.ts runs three separate syscalls:

hooks.beforeRename?.(tmp, target);
hooks.validateBeforeRename?.(target);
effective.rename(tmp, target);

rename(2) replaces unconditionally. A Codex writer that rewrote auth.json with identical bytes owns the target afterwards — different inode, same hash — and the publisher would overwrite it. The user's own codex login result is silently replaced by a token OpenCodex staged from an earlier read, with no error and no recovery path: the next codex invocation just uses a credential the user did not authorize.

The change

MainAuthJsonCredential now carries the target's dev/ino alongside rawSha256, and assertMainAuthJsonSnapshotUnchanged compares both. An unreadable identity on either side fails closed — unprovable is not the same as equal.

Refusal keeps the existing signal, MainAuthJsonChangedDuringRefreshError, so callers retry against the new state rather than proceeding with a credential they no longer own.

Honest scope

This narrows the window; it does not eliminate it. An identity check still happens before the rename rather than atomically with it. A genuine compare-and-swap needs renameat2(RENAME_EXCHANGE) on Linux or an equivalent elsewhere, and Bun exposes neither — rg for renameat2, RENAME_EXCH, linkSync, O_EXCL, and exchangedata across src/ finds nothing to build on.

So this closes the case the old guard provably missed and shrinks the remaining one. #2999 should stay open for the atomic primitive, or be re-scoped to it.

Verification

Exact head 16a9dad96:

  • bun test ./tests/codex-main-account-refresh.test.ts7 pass, 0 fail, 24 expect() calls, including four new cases.
  • bun test ./tests/native-main-claim.test.ts ./tests/responses-native-main-refresh.test.ts ./tests/codex-auth-context.test.ts77 pass, 0 fail.
  • bun x tsc --noEmit — clean.

The new cases follow the issue's own reproduction, using the setMainAuthJsonBeforeRenameHookForTests seam it names in step 5:

  1. A writer landing at the rename boundary with different bytes → preserved byte-for-byte.
  2. A writer replacing with identical bytes but a new inode → still refused. This is the case a content hash cannot see.
  3. The canonical target survives a refused publication, with no residual temp files — losing auth.json would be worse than losing the refresh.
  4. An uncontested publication still succeeds, so the guard does not make the ordinary path fail closed.

Red-green: removing the identity check while keeping the content hash turns case 2 red (6 pass / 1 fail) and leaves the rest green. That is the proof the check earns its place rather than duplicating the hash.

Checklist

  • Targets dev
  • Credential-handling change; the trust boundary is described above and the guard fails closed
  • Regression follows the issue's stated reproduction, and the identity-specific case is proven red-green
  • Scope limit stated rather than implied — the remaining window is named, not papered over
  • No workflow, release-automation, or OAuth-flow change

…shing

The native-main publisher hashed auth.json and re-checked the hash before
renaming its staged file over it. Both guards asked whether the CONTENT matched;
neither asked whether it was still the same file.

That is the weaker question at a boundary where rename(2) replaces
unconditionally. A Codex writer that rewrites auth.json with identical bytes
owns the target afterwards, and the publisher would overwrite it - silently
replacing the user's own codex login result with a token staged from an earlier
read.

Carry the target's dev+ino alongside the hash and compare both. An unreadable
identity on either side fails closed: unprovable is not the same as equal.

This narrows the window rather than eliminating it. A truly atomic
compare-and-swap needs renameat2(RENAME_EXCHANGE) or equivalent, which Bun does
not expose.

Refs #2999
@lidge-jun
lidge-jun requested a review from Ingwannu as a code owner September 1, 2026 18:19
@lidge-jun
lidge-jun merged commit c17bc94 into dev Sep 1, 2026
6 checks passed
@lidge-jun
lidge-jun deleted the codex/2999-publication-identity branch September 1, 2026 18:19
@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Deterministic PR hygiene checks passed.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 1, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-01T18:22:54.845838Z 16a9dad PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 16a9dad96c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/codex/main-account.ts
return {
path,
rawSha256: sha256(raw),
identity: statIdentity(path),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Bind the recorded identity to the bytes read

If an external writer replaces auth.json with identical bytes between readFileSync and this subsequent stat, the snapshot combines the old read with the replacement's identity; every pre-rename check can then pass and overwrite that replacement. Read through an open descriptor and obtain its identity with fstatSync, or use a stat-read-stat sequence that rejects an identity change, so the hash and identity describe the same filesystem object.

AGENTS.md reference: src/AGENTS.md:L20-L20

Useful? React with 👍 / 👎.

Comment thread src/codex/main-account.ts
Comment on lines +93 to +96
function statIdentity(path: string): { dev: number; ino: number } | null {
try {
const stat = statSync(path);
return { dev: Number(stat.dev), ino: Number(stat.ino) };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve full-width filesystem identifiers

When auth.json resides on a filesystem whose 64-bit device or inode value exceeds Number.MAX_SAFE_INTEGER, the default numeric stat result—and the explicit Number(...) conversion—can lose low bits, allowing distinct file identities to compare equal and defeating this credential-publication guard. Request bigint stats with statSync(path, { bigint: true }) and retain bigint values for the comparison.

AGENTS.md reference: src/AGENTS.md:L20-L20

Useful? React with 👍 / 👎.

@lidge-jun

Copy link
Copy Markdown
Owner Author

리뷰 · 우선순위 74 / 80

이 PR은 이미 dev에 합쳐진 상태입니다(합본 c17bc94c2). 지금 헤드 519bba745 기준으로 보면, 네이티브 메인 계정 토큰을 새로 고친 뒤 auth.json에 다시 쓸 때, 예전에는 파일 내용의 해시만 다시 확인했습니다. 그 검사는 “지금 디스크에 있는 바이트가 내가 읽었을 때와 같으냐”만 묻습니다. 그런데 실제 쓰기 경계에서는 rename(2)가 무조건 덮어씁니다. 그래서 Codex CLI가 같은 바이트로 auth.json을 새로 만들어 같은 자리에 올려 놓으면, 아이노드(파일 정체)는 바뀌었는데 해시는 그대로라서, 예전 가드가 “괜찮다”고 착각하고 OpenCodex가 스테이징해 둔 예전 토큰으로 사용자 로그인 결과를 조용히 덮어쓸 수 있었습니다. 이 PR은 그 “내용만 같다” 구멍을 “같은 파일인가” 질문으로 좁힙니다.

구체적으로 MainAuthJsonCredentialidentity: { dev, ino } | null을 붙이고, statIdentity로 읽은 순간의 디바이스/아이노드를 같이 들고 다닙니다. 게시 직전 assertMainAuthJsonSnapshotUnchanged가 해시뿐 아니라 assertMainAuthJsonIdentityUnchanged로 정체도 비교합니다. 어느 한쪽이든 정체를 못 읽으면 통과시키지 않습니다. “증명 못 함 = 같다”가 아니라 “증명 못 함 = 거절”입니다. 거절 신호는 그대로 MainAuthJsonChangedDuringRefreshError라서, 호출 쪽은 새 상태를 다시 읽고 재시도하는 기존 경로를 타면 됩니다. 자격 증명을 강제로 밀어 넣는 새 경로를 만들지 않은 점이 안전합니다.

테스트도 이슈 #2999가 가리킨 재현 모양을 그대로 따라갑니다. setMainAuthJsonBeforeRenameHookForTests로 rename 직전 외부 작가를 흉내 내서, (1) 다른 바이트로 바뀐 경우 보존, (2) 같은 바이트인데 아이노드만 바뀐 경우도 거절, (3) 거절 후에도 auth.json이 사라지지 않고 잔여 임시 파일이 남지 않음, (4) 경쟁 없는 보통 게시는 성공 — 네 가지를 묶었습니다. 특히 (2)는 내용 해시만으로는 빨간불이 안 켜지는 케이스라서, 아이덴티티 검사가 “해시의 복붙”이 아니라 실제로 새 가치를 만든다는 증거가 됩니다. types.ts/config.ts 대규모 분리 캠페인과는 겹치지 않습니다. 손댄 곳은 src/codex/main-account.ts와 리프레시 테스트뿐이라, 분리 때문에 리베이스를 강요하거나 닫아야 할 PR이 아닙니다.

다만 PR 본문이 솔직히 말한 것처럼, 이 가드는 창을 줄일 뿐 원자적 비교-교환은 아닙니다. src/config/atomic-write.tsbeforeRenamevalidateBeforeRenamerename 순서로 세 번의 시스템 콜을 거칩니다. 마지막 검사와 rename 사이에는 여전히 틈이 있습니다. 진짜 CAS는 Linux의 renameat2(RENAME_EXCHANGE) 같은 원시 연산이 필요한데, Bun이 그걸 노출하지 않아서 지금 트리에는 기반이 없습니다. 그래서 #2999를 “다 고쳤다”고 닫기보다는, 원자 원시 연산 후속으로 남겨 두는 쪽이 PR 설명과 맞습니다. 현재 dev에는 이미 #3112 쪽 조율(CLAIM)과 이번 게시 가드가 같이 올라가 있어, #2999의 “조율 반 + 게시 반” 중 게시 반의 실질 구멍은 크게 줄었습니다.

점수 74는 “현재 dev에 이미 반영된 자격증명 경계 수정으로서 방향이 옳고 테스트가 핵심 케이스를 증명한다”는 뜻입니다. 80을 안 준 이유는 (a) 검사와 rename 사이 TOCTOU가 남아 있고, (b) 읽기 경로에서 내용 해시와 stat가 서로 다른 순간이라 그 둘 사이에도 작은 틈이 있으며, (c) Windows처럼 아이노드 의미가 약한 플랫폼에서는 정체 검사의 날카로움이 Linux만큼은 아닐 수 있기 때문입니다. 그래도 예전 “해시만” 가드보다 분명히 강하고, 사용자 codex login 결과를 조용히 덮어쓰는 실패 모드를 막는 쪽으로 기울어 있어 유지·합본 판단은 이미 옳게 끝난 상태로 보입니다.

라인 105-127 (src/codex/main-account.ts readMainAuthJsonCredential) - readFileSync로 바이트를 읽은 뒤 따로 statIdentity(path)를 호출한다. 두 순간 사이에 외부 작가가 파일을 갈아끼우면 해시와 아이덴티티가 서로 다른 세대에서 올 수 있다. 한 번의 open/fd.stat로 묶으면 이 틈이 줄어든다.
라인 93-99 (statIdentity) / 라인 96 - Number(stat.dev), Number(stat.ino)로 강제한다. 지금 Bun에서는 number라 문제 없어 보이지만, 런타임이 bigint를 주면 큰 inode에서 정밀도 손실이 날 수 있다. bigint를 그대로 비교하거나 문자열로 정규화하는 편이 더 안전하다.
라인 190-192 (src/config/atomic-write.ts) - validateBeforeRename 직후 effective.rename이 이어지므로, 검사와 rename 사이 창은 이 PR로도 닫히지 않는다. PR이 이미 인정한 한계이며, 원자 CAS 없이는 구조적으로 남는다.
라인 168-175 (assertMainAuthJsonIdentityUnchanged) - 정체를 못 읽으면 무조건 거절(fail-closed)한다. 방향은 맞지만, 일시적 권한/공유 잠금 오류가 잦은 환경에서는 거절→재시도 폭풍이 늘 수 있다. 관측(로그/메트릭)이 없으면 현장 진단이 어렵다.
Windows 경로 - 같은 dev/ino 비교가 Linux만큼 “같은 파일”을 보장하지 않을 수 있다. Windows에서 더 강한 파일 ID가 필요하면 후속 이슈로 분리하는 편이 낫다.

메인테이너의 판단이 필요한 지점

  • #2999를 이 PR(합본)만으로 닫을지, 아니면 원자 CAS(renameat2 등) 후속으로 계속 열어둘지
  • readFileSync+별도 stat 틈을 같은 fd/fstat로 묶는 작은 후속을 당장 넣을지
  • Windows에서 inode 비교의 신뢰도를 별도 이슈로 받을지, 지금 수준으로 충분한지
  • types.ts/config.ts 분리 캠페인과는 무관(닫을 대상 아님). 중복 PR도 아님

너의 추천
이미 dev에 들어가 있으니 추가 머지 작업은 없다. #2999는 원자 비교-교환 원시 연산이 Bun/플랫폼에 생길 때까지 열어 두고, 본 PR 범위는 “게시 반의 내용-해시 구멍 축소”로 기록해 두자. 여력이 있으면 readMainAuthJsonCredential에서 내용과 정체를 한 번의 파일 핸들로 같이 잡는 후속 패치만 작게 열면 된다. 라벨 교체나 리베이스 강요는 하지 말 것.

이 댓글은 grok-bot이 작성했습니다

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant