From e09b60e4e488d2b04307f1eb2d3ba43437ab4dfd Mon Sep 17 00:00:00 2001 From: Vaxaxas <250858849+Vaxaxas@users.noreply.github.com> Date: Tue, 20 Oct 2026 04:14:36 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(chromeauth):=20=E4=BF=AE=E5=BE=A9=20Chr?= =?UTF-8?q?ome=20Web=20Data=20=E9=8E=96=E5=AE=9A=E7=84=A1=E6=B3=95?= =?UTF-8?q?=E8=AE=80=E5=8F=96=E5=8F=8A=E9=9D=9E=20DBSC=20=E5=B8=B3?= =?UTF-8?q?=E8=99=9F=E5=8C=AF=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 實作 openWebData 拷貝 Web Data 及其 WAL 日誌快照至臨時目錄讀取,解決 Chrome 運行時 SQLITE_BUSY (database is locked) 導致帳號未被偵測到的問題 - 放寬 Importable 與 readTokenService 對 bindingKey 的強制非空要求,允許無 DBSC 設備綁定之 v20 token 正常識別為可匯入 - 在 fetchGoogleCookies 中新增無 wrappedKey 時直接調用 requestMultilogin 的降級流程,並支援非 DBSC 標頭編碼與存儲 Co-Authored-By: Claude Code --- internal/aistudio/auth.go | 2 +- internal/chromeauth/platform_windows.go | 61 ++++++++++++++++++++++--- internal/chromeauth/protocol.go | 38 ++++++++++++--- 3 files changed, 87 insertions(+), 14 deletions(-) diff --git a/internal/aistudio/auth.go b/internal/aistudio/auth.go index be1e3d81..e631725c 100644 --- a/internal/aistudio/auth.go +++ b/internal/aistudio/auth.go @@ -57,7 +57,7 @@ type AuthSource struct { 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/platform_windows.go b/internal/chromeauth/platform_windows.go index 17a36066..a0dd2df4 100644 --- a/internal/chromeauth/platform_windows.go +++ b/internal/chromeauth/platform_windows.go @@ -6,6 +6,7 @@ import ( "database/sql" "encoding/json" "fmt" + "io" "os" "path/filepath" "sort" @@ -78,11 +79,11 @@ func discoverPlatform(chromeRoot string) ([]Account, error) { continue } seen[identity.Gaia] = true - _, encrypted, bindingKey, tokenErr := readTokenService(chromeRoot, profile, identity.Gaia) + _, encrypted, _, tokenErr := readTokenService(chromeRoot, profile, 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: tokenErr == nil && strings.HasPrefix(string(encrypted), "v20"), }) } } @@ -132,15 +133,61 @@ func profileLocale(chromeRoot string, profile string) string { return "" } -func readTokenService(chromeRoot string, profile string, gaiaID string) (string, []byte, []byte, error) { +func copyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + 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 +} + +func openWebData(chromeRoot string, profile string) (*sql.DB, func(), error) { databasePath := filepath.Join(chromeRoot, profile, "Web Data") - uri := "file:" + filepath.ToSlash(databasePath) + "?mode=ro&immutable=1" + cleanup := func() {} + + tempDir, err := os.MkdirTemp("", "aistudio2api-webdata-*") + if err == nil { + tempDb := filepath.Join(tempDir, "Web Data") + if copyFile(databasePath, tempDb) == nil { + _ = copyFile(databasePath+"-wal", tempDb+"-wal") + _ = copyFile(databasePath+"-shm", tempDb+"-shm") + databasePath = tempDb + cleanup = func() { + _ = os.RemoveAll(tempDir) + } + } else { + _ = os.RemoveAll(tempDir) + } + } + + uri := "file:" + filepath.ToSlash(databasePath) + "?mode=ro" database, err := sql.Open("sqlite", uri) if err != nil { - return "", nil, nil, fmt.Errorf("打开 %s Web Data: %w", profile, err) + cleanup() + return nil, nil, fmt.Errorf("打开 %s Web Data: %w", profile, err) } - defer database.Close() database.SetMaxOpenConns(1) + return database, func() { + database.Close() + cleanup() + }, nil +} + +func readTokenService(chromeRoot string, profile string, gaiaID string) (string, []byte, []byte, error) { + database, closeDb, err := openWebData(chromeRoot, profile) + if err != nil { + return "", nil, nil, err + } + defer closeDb() rows, err := database.Query("SELECT service, encrypted_token, binding_key FROM token_service WHERE service = ?", "AccountId-"+gaiaID) if err != nil { @@ -168,7 +215,7 @@ func readTokenService(chromeRoot string, profile string, gaiaID string) (string, if !strings.HasPrefix(service, "AccountId-") { return "", nil, nil, fmt.Errorf("%s 的 token_service service 格式异常", profile) } - if len(encryptedToken) == 0 || len(bindingKey) == 0 { + if len(encryptedToken) == 0 { return "", nil, nil, fmt.Errorf("%s 的 token_service 缺少认证材料", profile) } return strings.TrimPrefix(service, "AccountId-"), encryptedToken, bindingKey, nil diff --git a/internal/chromeauth/protocol.go b/internal/chromeauth/protocol.go index 65166e2c..8c1b021d 100644 --- a/internal/chromeauth/protocol.go +++ b/internal/chromeauth/protocol.go @@ -75,20 +75,44 @@ type deviceBindingKey interface { } 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 { + status, resp, err := requestMultilogin(ctx, client, gaiaID, token, "") + if err != nil { + return nil, err + } + if status == http.StatusOK && resp.Status == "OK" && len(resp.Cookies) > 0 { + for _, required := range []string{"SAPISID", "__Secure-1PSID"} { + found := false + for _, c := range resp.Cookies { + if c.Name == required { + found = true + break + } + } + if !found { + return nil, fmt.Errorf("OAuthMultilogin 缺少核心 Cookie: %s", required) + } + } + return resp.Cookies, nil + } + return nil, multiloginStatusError("multilogin", status, resp.Status) + } + + bindingKey, err := openDeviceBindingKey(wrappedKey) if err != nil { return nil, err } - client, err := newOAuthClient(proxyURL) + 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 { @@ -202,7 +226,9 @@ func findChallenge(response multiloginResponse) string { func encodeMultiOAuthHeader(gaiaID string, token string, assertion string) string { account := appendBytesField(nil, 1, []byte(gaiaID)) account = appendBytesField(account, 2, []byte(token)) - account = appendBytesField(account, 3, []byte(assertion)) + if assertion != "" { + account = appendBytesField(account, 3, []byte(assertion)) + } return base64.RawURLEncoding.EncodeToString(appendBytesField(nil, 1, account)) } From 61a159475355d71714f9154e2ceccb2ef98896bc Mon Sep 17 00:00:00 2001 From: Mag1cFall Date: Tue, 20 Oct 2026 12:59:23 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20Chrome=20=E5=AF=BC=E5=85=A5=E5=A4=8D?= =?UTF-8?q?=E5=88=B6=20Web=20Data=20=E8=AF=BB=E5=8F=96=EF=BC=8C=E6=97=A0?= =?UTF-8?q?=E7=BB=91=E5=AE=9A=E5=AF=86=E9=92=A5=E8=B4=A6=E5=8F=B7=E7=94=A8?= =?UTF-8?q?=20MultiBearer=20=E7=BB=AD=E7=AD=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Web Data 与其 WAL 日志按 Profile 复制一次到临时目录后以文件 URI 只读打开,路径中的 # 与 % 按 URI 转义,读取后删除副本,复制失败时报错 - 没有设备绑定密钥的账号以 MultiBearer 头请求 OAuthMultilogin 取得明文 Cookie,绑定与无绑定路径拆为两个函数并共用 Cookie 核对 - 协议文档、README 与注释写明无绑定密钥账号的导入与续签方式 --- README.md | 2 +- README_en.md | 2 +- docs/protocol.md | 13 +-- internal/aistudio/auth.go | 2 +- internal/chromeauth/auth.go | 4 +- internal/chromeauth/platform_windows.go | 111 +++++++++++++----------- internal/chromeauth/protocol.go | 91 ++++++++++--------- 7 files changed, 123 insertions(+), 102 deletions(-) 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 e631725c..acd8a4fd 100644 --- a/internal/aistudio/auth.go +++ b/internal/aistudio/auth.go @@ -53,7 +53,7 @@ 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"` 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 a0dd2df4..0b9e2be4 100644 --- a/internal/chromeauth/platform_windows.go +++ b/internal/chromeauth/platform_windows.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "io" + "net/url" "os" "path/filepath" "sort" @@ -73,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, _, 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"), + Importable: recordsErr == nil && found && strings.HasPrefix(string(record.encryptedToken), "v20"), }) } } @@ -133,92 +135,99 @@ func profileLocale(chromeRoot string, profile string) string { return "" } -func copyFile(src, dst string) error { +// copyFile 把 src 复制为 dst +func copyFile(src string, dst string) error { in, err := os.Open(src) if err != nil { return err } 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 } -func openWebData(chromeRoot string, profile string) (*sql.DB, func(), error) { - databasePath := filepath.Join(chromeRoot, profile, "Web Data") - cleanup := func() {} - +// 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 { - tempDb := filepath.Join(tempDir, "Web Data") - if copyFile(databasePath, tempDb) == nil { - _ = copyFile(databasePath+"-wal", tempDb+"-wal") - _ = copyFile(databasePath+"-shm", tempDb+"-shm") - databasePath = tempDb - cleanup = func() { - _ = os.RemoveAll(tempDir) - } - } else { - _ = os.RemoveAll(tempDir) + if err != nil { + return nil, nil, fmt.Errorf("创建 Web Data 临时目录: %w", err) + } + defer func() { + if err != nil { + os.RemoveAll(tempDir) } + }() + 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) } - - uri := "file:" + filepath.ToSlash(databasePath) + "?mode=ro" - database, err := sql.Open("sqlite", uri) - if err != nil { - cleanup() + 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() - cleanup() + os.RemoveAll(tempDir) }, nil } -func readTokenService(chromeRoot string, profile string, gaiaID string) (string, []byte, []byte, error) { - database, closeDb, err := openWebData(chromeRoot, profile) +// 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, nil, err + return nil, err } - defer closeDb() - - rows, err := database.Query("SELECT service, encrypted_token, binding_key FROM token_service WHERE service = ?", "AccountId-"+gaiaID) + defer closeDatabase() + rows, err := database.Query("SELECT service, encrypted_token, binding_key FROM token_service WHERE service LIKE 'AccountId-%'") if err != nil { - return "", nil, nil, fmt.Errorf("读取 %s token_service: %w", profile, err) + return nil, fmt.Errorf("读取 %s token_service: %w", profile, err) } defer rows.Close() - if !rows.Next() { - if err := rows.Err(); err != nil { - return "", nil, nil, fmt.Errorf("读取 %s token_service: %w", profile, err) + 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) } - return "", nil, nil, fmt.Errorf("%s 的 token_service 记录数为 0", profile) - } - 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) - } - if rows.Next() { - return "", nil, nil, fmt.Errorf("%s 的 token_service 记录数大于 1", profile) + 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 { + 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 8c1b021d..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,36 +76,36 @@ 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) { client, err := newOAuthClient(proxyURL) if err != nil { return nil, err } defer client.CloseIdleConnections() - if len(wrappedKey) == 0 { - status, resp, err := requestMultilogin(ctx, client, gaiaID, token, "") - if err != nil { - return nil, err - } - if status == http.StatusOK && resp.Status == "OK" && len(resp.Cookies) > 0 { - for _, required := range []string{"SAPISID", "__Secure-1PSID"} { - found := false - for _, c := range resp.Cookies { - if c.Name == required { - found = true - break - } - } - if !found { - return nil, fmt.Errorf("OAuthMultilogin 缺少核心 Cookie: %s", required) - } - } - return resp.Cookies, nil - } - return nil, multiloginStatusError("multilogin", status, resp.Status) + 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 + } + 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 @@ -141,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 + } + for index := range second.Cookies { + if second.Cookies[index].Value, err = hpkeOpen(ephemeralPrivateKey, second.Cookies[index].Value); err != nil { + return nil, err + } } + return second.Cookies, nil +} - names := make(map[string]struct{}, len(second.Cookies)) - for index := range second.Cookies { - cookie := &second.Cookies[index] +// 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 nil, fmt.Errorf("OAuthMultilogin Cookie 格式异常") - } - cookie.Value, err = hpkeOpen(ephemeralPrivateKey, cookie.Value) - if err != nil { - return nil, err + 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) { @@ -185,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) @@ -226,9 +239,7 @@ func findChallenge(response multiloginResponse) string { func encodeMultiOAuthHeader(gaiaID string, token string, assertion string) string { account := appendBytesField(nil, 1, []byte(gaiaID)) account = appendBytesField(account, 2, []byte(token)) - if assertion != "" { - account = appendBytesField(account, 3, []byte(assertion)) - } + account = appendBytesField(account, 3, []byte(assertion)) return base64.RawURLEncoding.EncodeToString(appendBytesField(nil, 1, account)) }