diff --git a/README.md b/README.md index 93b6ac73..978669c5 100644 --- a/README.md +++ b/README.md @@ -553,7 +553,7 @@ Go 负责编码、调度、流式解码与公开协议;受 WAA 保护的 `Gene - **客户端管理历史**: Chat、Anthropic 和 Gemini 请求由客户端提交完整对话上下文 - **AI Studio 历史**: API 请求不保存到官网历史;`TEMPORARY_CHAT=true` 还会关闭 WAA 预热页的自动保存 - **Responses 会话**: `previous_response_id` 仅在当前进程内保存,重启后不会保留 -- **认证有效期**: Chrome 导入账户保留 DBSC 续签材料,续签在 Windows 上执行,Linux、macOS 与 Docker 中的 Chrome 导入账户失效后重新从 Windows 复制;其他账户失效时,若本机 Chrome 登录着同一邮箱,服务从 Chrome 导入该邮箱的材料续签,此后按 Chrome 导入账户续签,否则在账户页重新登录 +- **认证有效期**: Chrome 导入账户保留 OAuth 续签材料,续签在 Windows 上执行,Linux、macOS 与 Docker 中的 Chrome 导入账户失效后重新从 Windows 复制;其他账户失效时,若本机 Chrome 登录着同一邮箱,服务从 Chrome 导入该邮箱的材料续签,此后按 Chrome 导入账户续签,否则在账户页重新登录 ## 故障排除 diff --git a/README_en.md b/README_en.md index 53728a2b..f118cea6 100644 --- a/README_en.md +++ b/README_en.md @@ -553,7 +553,7 @@ With `WAA_BACKEND=go`, WAA runs inside the service process, emulates the Firefox - **Client-Managed History**: Clients submit complete conversation context for Chat, Anthropic, and Gemini requests - **AI Studio History**: API requests are not saved to website history; `TEMPORARY_CHAT=true` also disables autosave for the WAA prewarm page - **Responses Sessions**: `previous_response_id` is stored only in the current process and is cleared on restart -- **Authentication Expiry**: Chrome imports retain DBSC renewal material and renew on Windows; when a Chrome import on Linux, macOS, or Docker expires, copy it from Windows again. When another account expires and the local Chrome is signed in to the same email, the service renews it with that email's material from Chrome and renews it as a Chrome import from then on; otherwise log in again on the Accounts page +- **Authentication Expiry**: Chrome imports retain OAuth renewal material and renew on Windows; when a Chrome import on Linux, macOS, or Docker expires, copy it from Windows again. When another account expires and the local Chrome is signed in to the same email, the service renews it with that email's material from Chrome and renews it as a Chrome import from then on; otherwise log in again on the Accounts page ## Troubleshooting diff --git a/docs/protocol.md b/docs/protocol.md index 446ff780..c66fc63c 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -94,18 +94,19 @@ Windows Chrome 导入从 Profile 恢复 OAuth 与 Device Bound Session Credentia ```text Chrome Local State + Profile Preferences + Web Data/token_service - -> Gaia ID、v20 refresh token 密文、wrapped binding key + -> Gaia ID、v20 refresh token 密文、wrapped binding key(可为空) -> 解开 App-Bound v20 主密钥 -> AES-256-GCM 解密 refresh token - -> OAuthMultilogin sentinel 请求取得 DBSC challenge - -> NCrypt 设备密钥签发 ES256 assertion - -> X25519/HPKE 解密服务端 Cookie + -> 有 binding key:OAuthMultilogin sentinel 请求取得 DBSC challenge + -> NCrypt 设备密钥签发 ES256 assertion + -> X25519/HPKE 解密服务端 Cookie + -> 没有 binding key:MultiBearer 头的 OAuthMultilogin 直接返回明文 Cookie -> 保存 Playwright storage state 结构与续签材料 ``` -`Local State.os_crypt.app_bound_encrypted_key` 使用 Base64 编码并带 `APPB` 前缀。程序把内嵌 ABE helper 加载到独立、隐藏的 Chrome 进程中,取得 32 字节主密钥;临时进程树由 Windows Job Object 管理。`token_service.encrypted_token` 使用 `v20 || nonce[12] || ciphertext+tag`,以该主密钥执行 AES-GCM 解密。 +Chrome 运行期间以排他锁打开 `Web Data`,程序把 `Web Data` 与 `Web Data-wal` 复制到临时目录后只读打开,读取后删除副本。`Local State.os_crypt.app_bound_encrypted_key` 使用 Base64 编码并带 `APPB` 前缀。程序把内嵌 ABE helper 加载到独立、隐藏的 Chrome 进程中,取得 32 字节主密钥;临时进程树由 Windows Job Object 管理。`token_service.encrypted_token` 使用 `v20 || nonce[12] || ciphertext+tag`,以该主密钥执行 AES-GCM 解密。 -OAuthMultilogin 使用 `MultiOAuth` 头。第一次 assertion 为 `DBSC_CHALLENGE_IF_REQUIRED`,响应提供 challenge;第二次 assertion 的 JWT header 使用 `ES256` 与 `DEVICE_BOUND_SESSION_CREDENTIALS_ASSERTION`。payload 绑定 Google OAuth client、challenge、设备公钥 issuer 和临时 HPKE 公钥。Cookie 密文使用 X25519、HKDF-SHA256 与 AES-128-GCM 解密。 +设备绑定的账号 OAuthMultilogin 使用 `MultiOAuth` 头,`token_service.binding_key` 为空的账号使用 `MultiBearer :` 头,一次请求返回明文 Cookie。`MultiOAuth` 第一次 assertion 为 `DBSC_CHALLENGE_IF_REQUIRED`,响应提供 challenge;第二次 assertion 的 JWT header 使用 `ES256` 与 `DEVICE_BOUND_SESSION_CREDENTIALS_ASSERTION`。payload 绑定 Google OAuth client、challenge、设备公钥 issuer 和临时 HPKE 公钥。Cookie 密文使用 X25519、HKDF-SHA256 与 AES-128-GCM 解密。 Chrome 导入状态在 `storage-state.json` 的 `aistudio2api` 扩展中保存来源、Gaia ID、refresh token 与 wrapped binding key。登录页跳转、签名 Cookie 失效、HTTP `401` 与协议 Code 16 进入同一认证恢复流程,覆盖 Worker 预热、按需启动、普通 RPC、受保护 RPC 与 Live 建连。服务优先在同一账户出口续签 Cookie;保存的 OAuth 材料被拒绝时,从当前 Chrome 匹配原账户邮箱与 Gaia ID,更新来源材料。没有 `aistudio2api` 扩展的账户在本机 Chrome 登录着同一邮箱时,从 Chrome 导入该邮箱的材料并写入扩展,此后按 Chrome 导入账户续签。有效 Cookie 提交后使动态头失效、关闭该账户 WAA runtime,并重放一次。恢复失败的账户标为 `auth_required`,管理事件同步发布账户状态,后续调度使用其他合格账户。 diff --git a/internal/aistudio/auth.go b/internal/aistudio/auth.go index be1e3d81..acd8a4fd 100644 --- a/internal/aistudio/auth.go +++ b/internal/aistudio/auth.go @@ -53,11 +53,11 @@ type AuthSource struct { Email string `json:"email,omitempty"` } -// ChromeOAuthMaterial 保存 Chrome DBSC 续签材料 +// ChromeOAuthMaterial 保存 Chrome OAuth 续签材料,设备绑定密钥可为空 type ChromeOAuthMaterial struct { GaiaID string `json:"gaia_id"` RefreshToken string `json:"refresh_token"` - WrappedBindingKey []byte `json:"wrapped_binding_key"` + WrappedBindingKey []byte `json:"wrapped_binding_key,omitempty"` } // AuthExtension 保存 aistudio2api 认证扩展 diff --git a/internal/chromeauth/auth.go b/internal/chromeauth/auth.go index b8afc968..fec2cd7f 100644 --- a/internal/chromeauth/auth.go +++ b/internal/chromeauth/auth.go @@ -58,7 +58,7 @@ func Discover(chromeRoot string) ([]Account, error) { return discoverPlatform(chromeRoot) } -// Import 通过设备绑定 OAuth 材料生成 Playwright storage state +// Import 用本机 Chrome 的 OAuth 续签材料生成 Playwright storage state func Import(ctx context.Context, options ImportOptions) ([]ImportResult, error) { if err := ensurePlatformImport(); err != nil { return nil, err @@ -93,7 +93,7 @@ func Import(ctx context.Context, options ImportOptions) ([]ImportResult, error) return results, nil } -// Refresh 使用保存的设备绑定材料重新签发 Google Cookie +// Refresh 使用保存的 OAuth 续签材料重新签发 Google Cookie func Refresh(ctx context.Context, material aistudio.ChromeOAuthMaterial, proxy string) ([]aistudio.StateCookie, error) { if err := ensurePlatformImport(); err != nil { return nil, err diff --git a/internal/chromeauth/platform_windows.go b/internal/chromeauth/platform_windows.go index 17a36066..0b9e2be4 100644 --- a/internal/chromeauth/platform_windows.go +++ b/internal/chromeauth/platform_windows.go @@ -6,6 +6,8 @@ import ( "database/sql" "encoding/json" "fmt" + "io" + "net/url" "os" "path/filepath" "sort" @@ -72,17 +74,18 @@ func discoverPlatform(chromeRoot string) ([]Account, error) { }) continue } + records, recordsErr := readTokenServices(chromeRoot, profile) seen := map[string]bool{} for _, identity := range identities { if identity.Gaia == "" || seen[identity.Gaia] { continue } seen[identity.Gaia] = true - _, encrypted, bindingKey, tokenErr := readTokenService(chromeRoot, profile, identity.Gaia) + record, found := records[identity.Gaia] accounts = append(accounts, Account{ ID: profile + "/" + identity.Gaia, GaiaID: identity.Gaia, Profile: profile, DisplayName: info.Name, Email: identity.Email, Locale: locale, - Importable: tokenErr == nil && strings.HasPrefix(string(encrypted), "v20") && len(bindingKey) != 0, + Importable: recordsErr == nil && found && strings.HasPrefix(string(record.encryptedToken), "v20"), }) } } @@ -132,46 +135,99 @@ func profileLocale(chromeRoot string, profile string) string { return "" } -func readTokenService(chromeRoot string, profile string, gaiaID string) (string, []byte, []byte, error) { - databasePath := filepath.Join(chromeRoot, profile, "Web Data") - uri := "file:" + filepath.ToSlash(databasePath) + "?mode=ro&immutable=1" - database, err := sql.Open("sqlite", uri) +// copyFile 把 src 复制为 dst +func copyFile(src string, dst string) error { + in, err := os.Open(src) if err != nil { - return "", nil, nil, fmt.Errorf("打开 %s Web Data: %w", profile, err) + return err } - defer database.Close() - database.SetMaxOpenConns(1) + defer in.Close() + out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) + if err != nil { + return err + } + defer out.Close() + _, err = io.Copy(out, in) + return err +} - rows, err := database.Query("SELECT service, encrypted_token, binding_key FROM token_service WHERE service = ?", "AccountId-"+gaiaID) +// openWebData 把 Profile 的 Web Data 与其 WAL 日志复制到临时目录后只读打开,绕过运行中 Chrome 的排他锁并包含尚未检查点的写入 +func openWebData(chromeRoot string, profile string) (database *sql.DB, closeDatabase func(), err error) { + tempDir, err := os.MkdirTemp("", "aistudio2api-webdata-*") if err != nil { - return "", nil, nil, fmt.Errorf("读取 %s token_service: %w", profile, err) + return nil, nil, fmt.Errorf("创建 Web Data 临时目录: %w", err) } - defer rows.Close() - if !rows.Next() { - if err := rows.Err(); err != nil { - return "", nil, nil, fmt.Errorf("读取 %s token_service: %w", profile, err) + defer func() { + if err != nil { + os.RemoveAll(tempDir) } - return "", nil, nil, fmt.Errorf("%s 的 token_service 记录数为 0", profile) + }() + databasePath := filepath.Join(chromeRoot, profile, "Web Data") + copyPath := filepath.Join(tempDir, "Web Data") + if err = copyFile(databasePath, copyPath); err != nil { + return nil, nil, fmt.Errorf("复制 %s Web Data: %w", profile, err) + } + if err = copyFile(databasePath+"-wal", copyPath+"-wal"); err != nil && !os.IsNotExist(err) { + return nil, nil, fmt.Errorf("复制 %s Web Data-wal: %w", profile, err) + } + uri := url.URL{Scheme: "file", Path: "/" + filepath.ToSlash(copyPath), RawQuery: "mode=ro"} + if database, err = sql.Open("sqlite", uri.String()); err != nil { + return nil, nil, fmt.Errorf("打开 %s Web Data: %w", profile, err) + } + database.SetMaxOpenConns(1) + return database, func() { + database.Close() + os.RemoveAll(tempDir) + }, nil +} + +// tokenRecord 是 token_service 中一个账号的加密 refresh token 与 wrapped binding key +type tokenRecord struct { + encryptedToken []byte + bindingKey []byte +} + +// readTokenServices 读取 Profile 中全部 Google 账号的 token_service 记录,键为 Gaia ID +func readTokenServices(chromeRoot string, profile string) (map[string]tokenRecord, error) { + database, closeDatabase, err := openWebData(chromeRoot, profile) + if err != nil { + return nil, err } - var service string - var encryptedToken []byte - var bindingKey []byte - if err := rows.Scan(&service, &encryptedToken, &bindingKey); err != nil { - return "", nil, nil, fmt.Errorf("解析 %s token_service: %w", profile, err) + defer closeDatabase() + rows, err := database.Query("SELECT service, encrypted_token, binding_key FROM token_service WHERE service LIKE 'AccountId-%'") + if err != nil { + return nil, fmt.Errorf("读取 %s token_service: %w", profile, err) } - if rows.Next() { - return "", nil, nil, fmt.Errorf("%s 的 token_service 记录数大于 1", profile) + defer rows.Close() + records := map[string]tokenRecord{} + for rows.Next() { + var service string + var record tokenRecord + if err := rows.Scan(&service, &record.encryptedToken, &record.bindingKey); err != nil { + return nil, fmt.Errorf("解析 %s token_service: %w", profile, err) + } + records[strings.TrimPrefix(service, "AccountId-")] = record } if err := rows.Err(); err != nil { - return "", nil, nil, fmt.Errorf("读取 %s token_service: %w", profile, err) + return nil, fmt.Errorf("读取 %s token_service: %w", profile, err) + } + return records, nil +} + +// readTokenService 返回一个账号的 Gaia ID、加密 refresh token 与 wrapped binding key +func readTokenService(chromeRoot string, profile string, gaiaID string) (string, []byte, []byte, error) { + records, err := readTokenServices(chromeRoot, profile) + if err != nil { + return "", nil, nil, err } - if !strings.HasPrefix(service, "AccountId-") { - return "", nil, nil, fmt.Errorf("%s 的 token_service service 格式异常", profile) + record, found := records[gaiaID] + if !found { + return "", nil, nil, fmt.Errorf("%s 的 token_service 没有 %s 的记录", profile, gaiaID) } - if len(encryptedToken) == 0 || len(bindingKey) == 0 { + if len(record.encryptedToken) == 0 { return "", nil, nil, fmt.Errorf("%s 的 token_service 缺少认证材料", profile) } - return strings.TrimPrefix(service, "AccountId-"), encryptedToken, bindingKey, nil + return gaiaID, record.encryptedToken, record.bindingKey, nil } func naturalLess(left string, right string) bool { diff --git a/internal/chromeauth/protocol.go b/internal/chromeauth/protocol.go index 65166e2c..c7953934 100644 --- a/internal/chromeauth/protocol.go +++ b/internal/chromeauth/protocol.go @@ -38,8 +38,10 @@ func multiloginStatusError(phase string, statusCode int, status string) error { return err } +// multiloginURL 是 OAuthMultilogin 的地址 +var multiloginURL = "https://accounts.google.com/oauth/multilogin?source=ChromiumAccountReconcilorDice&reuseCookies=0" + const ( - multiloginURL = "https://accounts.google.com/oauth/multilogin?source=ChromiumAccountReconcilorDice&reuseCookies=0" oauthClientID = "77185425430.apps.googleusercontent.com" assertionAudience = "https://accounts.google.com/accountmanager" assertionSentinel = "DBSC_CHALLENGE_IF_REQUIRED" @@ -74,21 +76,45 @@ type deviceBindingKey interface { Close() } +// fetchGoogleCookies 用 refresh token 经 OAuthMultilogin 取得 Google Cookie,有设备绑定密钥时走 DBSC 挑战与 HPKE 解密,没有时一次 MultiBearer 请求取得明文 Cookie func fetchGoogleCookies(ctx context.Context, gaiaID string, token string, wrappedKey []byte, proxyURL string) ([]multiloginCookie, error) { - bindingKey, err := openDeviceBindingKey(wrappedKey) + client, err := newOAuthClient(proxyURL) if err != nil { return nil, err } - defer bindingKey.Close() - publicKey, spki, err := bindingKey.PublicKey() + defer client.CloseIdleConnections() + if len(wrappedKey) == 0 { + return fetchBearerCookies(ctx, client, gaiaID, token) + } + return fetchBoundCookies(ctx, client, gaiaID, token, wrappedKey) +} + +// fetchBearerCookies 为没有设备绑定的 token 请求一次 OAuthMultilogin,返回明文 Cookie +func fetchBearerCookies(ctx context.Context, client tls_client.HttpClient, gaiaID string, token string) ([]multiloginCookie, error) { + status, response, err := requestMultilogin(ctx, client, gaiaID, token, "") if err != nil { return nil, err } - client, err := newOAuthClient(proxyURL) + if status != http.StatusOK || response.Status != "OK" { + return nil, multiloginStatusError("bearer", status, response.Status) + } + if err := checkMultiloginCookies(response.Cookies); err != nil { + return nil, err + } + return response.Cookies, nil +} + +// fetchBoundCookies 为设备绑定的 token 取得 DBSC challenge、以设备密钥签发 assertion,再解密服务端返回的 Cookie +func fetchBoundCookies(ctx context.Context, client tls_client.HttpClient, gaiaID string, token string, wrappedKey []byte) ([]multiloginCookie, error) { + bindingKey, err := openDeviceBindingKey(wrappedKey) + if err != nil { + return nil, err + } + defer bindingKey.Close() + publicKey, spki, err := bindingKey.PublicKey() if err != nil { return nil, err } - defer client.CloseIdleConnections() firstStatus, first, err := requestMultilogin(ctx, client, gaiaID, token, assertionSentinel) if err != nil { @@ -117,31 +143,38 @@ func fetchGoogleCookies(ctx context.Context, gaiaID string, token string, wrappe if len(second.Directed) == 0 || bytes.Equal(second.Directed, []byte("null")) { return nil, fmt.Errorf("OAuthMultilogin 响应缺少 token_binding_directed_response") } - if len(second.Cookies) == 0 { - return nil, fmt.Errorf("OAuthMultilogin 响应缺少 Cookie") + if err := checkMultiloginCookies(second.Cookies); err != nil { + return nil, err } - - names := make(map[string]struct{}, len(second.Cookies)) for index := range second.Cookies { - cookie := &second.Cookies[index] - if cookie.Name == "" || cookie.Value == "" { - return nil, fmt.Errorf("OAuthMultilogin Cookie 格式异常") - } - cookie.Value, err = hpkeOpen(ephemeralPrivateKey, cookie.Value) - if err != nil { + if second.Cookies[index].Value, err = hpkeOpen(ephemeralPrivateKey, second.Cookies[index].Value); err != nil { return nil, err } + } + return second.Cookies, nil +} + +// checkMultiloginCookies 核对 OAuthMultilogin 返回的 Cookie 带有名称、值、域,并包含 SAPISID 与 __Secure-1PSID +func checkMultiloginCookies(cookies []multiloginCookie) error { + if len(cookies) == 0 { + return fmt.Errorf("OAuthMultilogin 响应缺少 Cookie") + } + names := make(map[string]struct{}, len(cookies)) + for _, cookie := range cookies { + if cookie.Name == "" || cookie.Value == "" { + return fmt.Errorf("OAuthMultilogin Cookie 格式异常") + } if cookie.Domain == "" && (cookie.Host == "" || strings.HasPrefix(cookie.Host, ".")) { - return nil, fmt.Errorf("OAuthMultilogin Cookie 域格式异常") + return fmt.Errorf("OAuthMultilogin Cookie 域格式异常") } names[cookie.Name] = struct{}{} } for _, required := range []string{"SAPISID", "__Secure-1PSID"} { if _, ok := names[required]; !ok { - return nil, fmt.Errorf("OAuthMultilogin 缺少核心 Cookie: %s", required) + return fmt.Errorf("OAuthMultilogin 缺少核心 Cookie: %s", required) } } - return second.Cookies, nil + return nil } func newOAuthClient(proxyURL string) (tls_client.HttpClient, error) { @@ -161,13 +194,17 @@ func newOAuthClient(proxyURL string) (tls_client.HttpClient, error) { return client, nil } +// requestMultilogin 发送一次 OAuthMultilogin:带 assertion 时用 MultiOAuth 头,没有设备绑定的 token 用 MultiBearer 头 func requestMultilogin(ctx context.Context, client tls_client.HttpClient, gaiaID string, token string, assertion string) (int, multiloginResponse, error) { - authorization := encodeMultiOAuthHeader(gaiaID, token, assertion) + authorization := "MultiBearer " + token + ":" + gaiaID + if assertion != "" { + authorization = "MultiOAuth " + encodeMultiOAuthHeader(gaiaID, token, assertion) + } request, err := http.NewRequestWithContext(ctx, http.MethodPost, multiloginURL, strings.NewReader(" ")) if err != nil { return 0, multiloginResponse{}, fmt.Errorf("创建 OAuthMultilogin 请求: %w", err) } - request.Header.Set("Authorization", "MultiOAuth "+authorization) + request.Header.Set("Authorization", authorization) request.Header.Set("Content-Type", "application/x-www-form-urlencoded") request.Header.Set("User-Agent", userAgent) response, err := client.Do(request)