mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-08 01:48:20 -10:00
fix(auth): distinguish safe ChatGPT token verification failures [skip ci]
Replace the generic JWT decode rejection with static signature, issuer, audience, expiry, not-before and required-claim diagnostics. Claim names are restricted to a fixed list; raw library errors and token values are never interpolated. Preserve all signature/time/issuer/audience/nonce and account checks, registration activation and plan-scope admission. Update the existing signed identity case to pin the diagnostics, including future not-before rejection. English/Chinese provider guidance separates device-clock advice from plan permission and asks only for safe error text. Verified: five existing SIWC cases passed; the same frozen batch's receipt API case passed (six total, zero failures). Ordinary native24 macOS ARM64 CLI built with all 4,249 inputs unchanged and no test-support feature. Windows report's exact rejected check remains unknown; no Windows fix, package/release or live ChatGPT account acceptance is claimed. Refs #7203, CW-7800.
This commit is contained in:
1 parent
a1adf81681
commit
8a37a91312
4 files changed
+75
-20
No files matched your search
+64
-19
@@ -20,7 +20,9 @@ use anyhow::{Context, Result, bail};
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use codewhale_config::ExternalCredentialReadGrant;
|
||||
use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode, decode_header, jwk::JwkSet};
|
||||
use jsonwebtoken::{
|
||||
Algorithm, DecodingKey, Validation, decode, decode_header, errors::ErrorKind, jwk::JwkSet,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use sha2::{Digest, Sha256};
|
||||
@@ -2579,8 +2581,39 @@ fn verify_chatgpt_jwt<T: serde::de::DeserializeOwned + Clone>(
|
||||
validation.validate_nbf = true;
|
||||
validation.leeway = 0;
|
||||
let claims = decode::<Value>(token, &key, &validation)
|
||||
.map_err(|_| {
|
||||
anyhow::anyhow!("ChatGPT token signature or identity claims failed verification")
|
||||
.map_err(|error| match error.kind() {
|
||||
ErrorKind::InvalidSignature => {
|
||||
anyhow::anyhow!("ChatGPT token signature verification failed")
|
||||
}
|
||||
ErrorKind::ExpiredSignature => anyhow::anyhow!(
|
||||
"ChatGPT token has expired; check your device date and time, then sign in again"
|
||||
),
|
||||
ErrorKind::ImmatureSignature => anyhow::anyhow!(
|
||||
"ChatGPT token is not valid yet; synchronize your device clock, then sign in again"
|
||||
),
|
||||
ErrorKind::InvalidIssuer => {
|
||||
anyhow::anyhow!("ChatGPT token issuer does not match the expected issuer")
|
||||
}
|
||||
ErrorKind::InvalidAudience => {
|
||||
anyhow::anyhow!("ChatGPT token audience does not match this registration")
|
||||
}
|
||||
ErrorKind::InvalidSubject => {
|
||||
anyhow::anyhow!("ChatGPT token subject does not match the expected account")
|
||||
}
|
||||
ErrorKind::MissingRequiredClaim(claim)
|
||||
if ["exp", "iss", "aud", "sub"].contains(&claim.as_str()) =>
|
||||
{
|
||||
anyhow::anyhow!("ChatGPT token is missing required {claim} claim")
|
||||
}
|
||||
ErrorKind::InvalidClaimFormat(claim)
|
||||
if ["exp", "iss", "aud", "sub", "nbf"].contains(&claim.as_str()) =>
|
||||
{
|
||||
anyhow::anyhow!("ChatGPT token has an invalid {claim} claim format")
|
||||
}
|
||||
ErrorKind::InvalidAlgorithm | ErrorKind::UnsupportedAlgorithm => anyhow::anyhow!(
|
||||
"ChatGPT token signature algorithm does not match the verification key"
|
||||
),
|
||||
_ => anyhow::anyhow!("ChatGPT token format or identity claims failed verification"),
|
||||
})?
|
||||
.claims;
|
||||
let exact_audience = claims.get("aud").is_some_and(|aud| {
|
||||
@@ -8328,35 +8361,47 @@ consent_version = 1
|
||||
let claims = signer.id_claims();
|
||||
let signed = signer.token(claims.clone());
|
||||
assert_eq!(verify(&signed).unwrap().subject, "test-sub");
|
||||
for (field, wrong) in [
|
||||
("iss", Value::from("https://other.invalid")),
|
||||
("aud", Value::from("oaiapp_other")),
|
||||
for (field, wrong, expected) in [
|
||||
("iss", Value::from("https://other.invalid"), "issuer"),
|
||||
("aud", Value::from("oaiapp_other"), "audience"),
|
||||
(
|
||||
"aud",
|
||||
serde_json::json!(["oaiapp_codewhale_test", "another-audience"]),
|
||||
"audience",
|
||||
),
|
||||
("exp", Value::from(1)),
|
||||
("nonce", Value::from("another-nonce")),
|
||||
("sub", Value::from("another-subject")),
|
||||
("sub", Value::from("")),
|
||||
("exp", Value::from(1), "expired"),
|
||||
(
|
||||
"nbf",
|
||||
Value::from(now_unix_secs().unwrap() + 3600),
|
||||
"not valid yet",
|
||||
),
|
||||
("nonce", Value::from("another-nonce"), "nonce"),
|
||||
("sub", Value::from("another-subject"), "different account"),
|
||||
("sub", Value::from(""), "no subject"),
|
||||
] {
|
||||
let mut changed = claims.clone();
|
||||
changed[field] = wrong;
|
||||
assert!(
|
||||
verify(&signer.token(changed)).is_err(),
|
||||
"accepted invalid {field}"
|
||||
);
|
||||
let error = verify(&signer.token(changed)).unwrap_err().to_string();
|
||||
assert!(error.contains(expected), "invalid {field}: {error}");
|
||||
}
|
||||
for required in ["iss", "aud", "exp", "sub", "nonce"] {
|
||||
let mut changed = claims.clone();
|
||||
changed.as_object_mut().unwrap().remove(required);
|
||||
assert!(
|
||||
verify(&signer.token(changed)).is_err(),
|
||||
"accepted missing {required}"
|
||||
);
|
||||
let error = verify(&signer.token(changed)).unwrap_err().to_string();
|
||||
let expected = if required == "nonce" {
|
||||
"nonce did not match".to_string()
|
||||
} else {
|
||||
format!("missing required {required} claim")
|
||||
};
|
||||
assert!(error.contains(&expected), "missing {required}: {error}");
|
||||
}
|
||||
let other_signer = SiwcSigningFixture::new();
|
||||
assert!(verify(&other_signer.token(claims.clone())).is_err());
|
||||
assert!(
|
||||
verify(&other_signer.token(claims.clone()))
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("signature verification failed")
|
||||
);
|
||||
let parts: Vec<_> = signed.split('.').collect();
|
||||
let bad_payload = URL_SAFE_NO_PAD.encode(br#"{"sub":"attacker"}"#);
|
||||
assert!(verify(&format!("{}.{}.{}", parts[0], bad_payload, parts[2])).is_err());
|
||||
|
||||
@@ -238,6 +238,14 @@ inference; choose another provider explicitly if you want another billing path.
|
||||
This experimental implementation retains one active registration. A picker
|
||||
for multiple saved ChatGPT accounts is not implemented yet.
|
||||
|
||||
If the browser succeeds but token verification fails, the error identifies the
|
||||
failed check: signature, issuer, audience, expiry, not-before time, or a
|
||||
missing required claim. For an expired or not-yet-valid token, synchronize
|
||||
your device date and time and start a new sign-in. Other verification failures
|
||||
are not evidence of a plan limitation. Report the error text and Codewhale
|
||||
version; never share tokens, callback codes, credential files or decoded claims.
|
||||
A failed verification does not activate the new registration.
|
||||
|
||||
Model discovery uses your granted bearer token at
|
||||
`GET https://api.openai.com/v1/models`; inference uses the public
|
||||
`POST https://api.openai.com/v1/responses` endpoint. The model picker keeps
|
||||
|
||||
+1
-1
@@ -830,7 +830,7 @@ owner = ".github/workflows/release-artifacts.yml"
|
||||
[[feature]]
|
||||
id = "chatgpt-plan-sign-in"
|
||||
name = "Sign in with ChatGPT"
|
||||
summary = "Uses official OAuth and the selected account catalog for eligible ChatGPT plan requests in the local OSS app."
|
||||
summary = "Uses official OAuth and the selected account catalog for eligible ChatGPT plan requests, with distinct token-verification errors."
|
||||
status = "experimental"
|
||||
surfaces = { tui = "preview", api = "partial", web = "none" }
|
||||
since = "0.10.1"
|
||||
|
||||
@@ -107,6 +107,8 @@ codewhale --provider openai-codex
|
||||
|
||||
`codewhale auth status --provider openai-codex` 和 `codewhale auth list` 会在 ID 令牌含有邮箱时显示账户标签;套餐和工作区信息只在相应字段存在时显示。标签用于识别账户,不能证明套餐权限;没有邮箱标签也不代表已经退出登录。状态检查只读取本地凭据,不向签发方发起请求:访问令牌已过期但仍存有刷新令牌时,会被视为结构上可用,这不保证续期一定成功。登录成功后,会显示已知的账户标签,并说明是否替换了先前的登录。
|
||||
|
||||
浏览器登录成功但令牌验证失败时,错误会区分签名、签发方、受众、过期时间、生效时间或缺少必需声明。令牌已过期或尚未生效时,请同步设备日期和时间后重新登录。其他验证失败不能证明套餐不符合条件。反馈时只提供错误文本和 Codewhale 版本,不要分享令牌、回调授权码、凭据文件或解码后的声明。验证失败不会激活新的注册。
|
||||
|
||||
模型目录使用已获授权的令牌请求 `GET https://api.openai.com/v1/models`;推理使用公共端点 `POST https://api.openai.com/v1/responses`。选择器保留官方显示名称和顺序,仅列出 `visibility: list` 的模型。目录不含凭据,并按已验证的签发方、签发的客户端 ID 和账户主体隔离;模型列表不能证明上下文窗口或思考档位。目录缺失、无效或超过 24 小时时,选择器不会列出任何账户模型。Codex CLI 导入令牌和旧版进程令牌变量不能授权这条官方路由。
|
||||
|
||||
符合条件的请求消耗 ChatGPT 套餐额度或点数。Plus 的五小时额度在各应用之间共享,不会为每个应用单独分配;这项五小时限制不适用于 Pro。应用还可能有各自的使用上限。可在 [ChatGPT 设置 → 使用情况](https://chatgpt.com/settings/usage) 查看额度和授权。额度耗尽时,Codewhale 不会自动换用 API 密钥或其他提供商。
|
||||
|
||||
Reference in new issue
Block a user