diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 34f02f9898..a62c068f21 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -2195,6 +2195,7 @@ func setDefaults() { viper.SetDefault("gateway.codex_image_generation_bridge_enabled", false) viper.SetDefault("gateway.openai_passthrough_allow_timeout_headers", false) viper.SetDefault("gateway.openai_compact_model", "gpt-5.4") + viper.SetDefault("gateway.live.max_session_duration_seconds", 3600) // OpenAI Responses WebSocket(默认开启;可通过 force_http 紧急回滚) viper.SetDefault("gateway.openai_ws.enabled", true) viper.SetDefault("gateway.openai_ws.mode_router_v2_enabled", false) diff --git a/backend/internal/handler/openai_live.go b/backend/internal/handler/openai_live.go index 466d84d685..b2bad0e0ea 100644 --- a/backend/internal/handler/openai_live.go +++ b/backend/internal/handler/openai_live.go @@ -171,6 +171,11 @@ func (h *OpenAIGatewayHandler) writeLiveCreateError(c *gin.Context, err error) { case errors.Is(err, service.ErrLiveUnavailable): h.errorResponse(c, http.StatusServiceUnavailable, "api_error", "Live is unavailable") default: + var attestationErr *service.LiveAttestationUnavailableError + if errors.As(err, &attestationErr) { + h.errorResponse(c, http.StatusServiceUnavailable, "api_error", attestationErr.Error()) + return + } var upstreamErr *service.UpstreamFailoverError if errors.As(err, &upstreamErr) && upstreamErr.StatusCode >= 400 && upstreamErr.StatusCode < 500 { h.errorResponse(c, upstreamErr.StatusCode, "invalid_request_error", "Live upstream rejected the request") diff --git a/backend/internal/handler/openai_live_test.go b/backend/internal/handler/openai_live_test.go index dde35f9f98..9df49654c8 100644 --- a/backend/internal/handler/openai_live_test.go +++ b/backend/internal/handler/openai_live_test.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "mime/multipart" + "net/http" "net/http/httptest" "testing" @@ -88,6 +89,19 @@ func TestLiveEnabledForAPIKey(t *testing.T) { })) } +func TestLiveAttestationErrorIsExplicit(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + context, _ := gin.CreateTestContext(recorder) + + (&OpenAIGatewayHandler{}).writeLiveCreateError(context, &service.LiveAttestationUnavailableError{ + Reason: "Live attestation is only supported when Sub2API runs on macOS", + }) + + require.Equal(t, http.StatusServiceUnavailable, recorder.Code) + require.Contains(t, recorder.Body.String(), "Sub2API runs on macOS") +} + func jsonPathString(t *testing.T, raw json.RawMessage, keys ...string) string { t.Helper() var value any diff --git a/backend/internal/platform/liveattestation/attestation.go b/backend/internal/platform/liveattestation/attestation.go new file mode 100644 index 0000000000..bfcb61dc36 --- /dev/null +++ b/backend/internal/platform/liveattestation/attestation.go @@ -0,0 +1,16 @@ +package liveattestation + +import ( + "context" + "errors" +) + +var ( + ErrUnsupportedPlatform = errors.New("Live attestation is only supported when Sub2API runs on macOS; Windows support is not implemented yet") + ErrChatGPTAppMissing = errors.New("Live attestation requires the official ChatGPT app on the Sub2API server") +) + +// Provider 在发起 Live 请求前生成 ChatGPT DeviceCheck attestation。 +type Provider interface { + Generate(ctx context.Context) (string, error) +} diff --git a/backend/internal/platform/liveattestation/attestation_darwin.go b/backend/internal/platform/liveattestation/attestation_darwin.go new file mode 100644 index 0000000000..9c0c560b89 --- /dev/null +++ b/backend/internal/platform/liveattestation/attestation_darwin.go @@ -0,0 +1,280 @@ +//go:build darwin + +package liveattestation + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/google/uuid" +) + +const ( + chatGPTApplicationPath = "/Applications/ChatGPT.app" + attestationTimeout = 5 * time.Second +) + +type darwinProvider struct { + appSessionID string + appPaths []string +} + +type deviceSignals struct { + SchemaVersion int `json:"schemaVersion"` + PreferredLanguages []string `json:"preferredLanguages"` + Locale string `json:"locale"` + Timezone string `json:"timezone"` + ScreenSizeSum int `json:"screenSizeSum"` + ScreenScale float64 `json:"screenScale"` + AppSessionID string `json:"appSessionId"` +} + +type macOSSignals struct { + Locale string `json:"locale"` + Languages []string `json:"languages"` + Timezone string `json:"timezone"` + Width float64 `json:"width"` + Height float64 `json:"height"` + Scale float64 `json:"scale"` +} + +func NewProvider() Provider { + paths := []string{chatGPTApplicationPath} + if home, err := os.UserHomeDir(); err == nil && strings.TrimSpace(home) != "" { + paths = append(paths, filepath.Join(home, "Applications", "ChatGPT.app")) + } + return &darwinProvider{ + appSessionID: uuid.NewString(), + appPaths: paths, + } +} + +func (p *darwinProvider) Generate(ctx context.Context) (string, error) { + if runtime.GOARCH != "arm64" { + return "", errors.New("Live attestation currently requires Apple Silicon; Intel macOS is not supported") + } + appPath, err := p.findApplication() + if err != nil { + return "", err + } + resourcesPath := filepath.Join(appPath, "Contents", "Resources") + nodePath := filepath.Join(resourcesPath, "cua_node", "bin", "node") + modulePath := filepath.Join(resourcesPath, "native", "devicecheck.node") + for filePath, label := range map[string]string{ + nodePath: "bundled Node.js runtime", + modulePath: "DeviceCheck native module", + } { + if info, statErr := os.Stat(filePath); statErr != nil || info.IsDir() { + return "", fmt.Errorf("%w: ChatGPT app is missing its %s", ErrChatGPTAppMissing, label) + } + } + + runCtx, cancel := context.WithTimeout(ctx, attestationTimeout) + defer cancel() + bundleID, err := readBundleIdentifier(runCtx, appPath) + if err != nil { + return "", err + } + signals, err := p.readSignals(runCtx) + if err != nil { + return "", err + } + signalsJSON, err := json.Marshal(signals) + if err != nil { + return "", fmt.Errorf("encode Live attestation signals: %w", err) + } + + command := exec.CommandContext(runCtx, nodePath, "-e", deviceCheckScript) + command.Env = []string{ + "PATH=/usr/bin:/bin", + "SUB2API_DEVICECHECK_MODULE=" + modulePath, + "SUB2API_ATTESTATION_BUNDLE_ID=" + bundleID, + "SUB2API_ATTESTATION_SIGNALS=" + string(signalsJSON), + } + var stdout bytes.Buffer + var stderr bytes.Buffer + command.Stdout = &stdout + command.Stderr = &stderr + if err := command.Run(); err != nil { + if errors.Is(runCtx.Err(), context.DeadlineExceeded) { + return "", errors.New("ChatGPT DeviceCheck token generation timed out") + } + reason := strings.TrimSpace(stderr.String()) + if len(reason) > 240 { + reason = reason[:240] + } + if reason == "" { + reason = err.Error() + } + return "", fmt.Errorf("ChatGPT DeviceCheck token generation failed: %s", reason) + } + header := strings.TrimSpace(stdout.String()) + if len(header) < 20 || len(header) > 16*1024 || !json.Valid([]byte(header)) { + return "", errors.New("ChatGPT DeviceCheck returned a malformed attestation") + } + return header, nil +} + +func (p *darwinProvider) findApplication() (string, error) { + for _, appPath := range p.appPaths { + info, err := os.Stat(appPath) + if err == nil && info.IsDir() { + return appPath, nil + } + } + return "", ErrChatGPTAppMissing +} + +func readBundleIdentifier(ctx context.Context, appPath string) (string, error) { + infoPlist := filepath.Join(appPath, "Contents", "Info.plist") + output, err := exec.CommandContext( + ctx, + "/usr/bin/plutil", + "-extract", + "CFBundleIdentifier", + "raw", + infoPlist, + ).Output() + if err != nil { + return "", fmt.Errorf("%w: cannot read its bundle identifier", ErrChatGPTAppMissing) + } + bundleID := strings.TrimSpace(string(output)) + if !strings.HasPrefix(bundleID, "com.openai.") { + return "", errors.New("the installed ChatGPT app has an unexpected bundle identifier") + } + return bundleID, nil +} + +func (p *darwinProvider) readSignals(ctx context.Context) (deviceSignals, error) { + const script = `ObjC.import("Foundation"); ObjC.import("AppKit"); +const screen = $.NSScreen.mainScreen; +const frame = screen.frame; +JSON.stringify({ + locale: ObjC.unwrap($.NSLocale.currentLocale.localeIdentifier), + languages: ObjC.deepUnwrap($.NSLocale.preferredLanguages), + timezone: ObjC.unwrap($.NSTimeZone.localTimeZone.name), + width: Number(frame.size.width), + height: Number(frame.size.height), + scale: Number(screen.backingScaleFactor) +})` + output, err := exec.CommandContext(ctx, "/usr/bin/osascript", "-l", "JavaScript", "-e", script).Output() + if err != nil { + return deviceSignals{}, fmt.Errorf("read macOS signals for Live attestation: %w", err) + } + var values macOSSignals + if err := json.Unmarshal(output, &values); err != nil { + return deviceSignals{}, fmt.Errorf("decode macOS signals for Live attestation: %w", err) + } + locale := truncateSignal(values.Locale, 64, "unknown") + languages := values.Languages + if len(languages) == 0 { + languages = []string{locale} + } + if len(languages) > 16 { + languages = languages[:16] + } + for index := range languages { + languages[index] = truncateSignal(languages[index], 64, locale) + } + scale := values.Scale + if scale <= 0 { + scale = 1 + } + return deviceSignals{ + SchemaVersion: 1, + PreferredLanguages: languages, + Locale: locale, + Timezone: truncateSignal(values.Timezone, 64, "unknown"), + ScreenSizeSum: max(0, int(values.Width+values.Height+0.5)), + ScreenScale: scale, + AppSessionID: truncateSignal(p.appSessionID, 128, uuid.NewString()), + }, nil +} + +func truncateSignal(value string, limit int, fallback string) string { + value = strings.TrimSpace(value) + if value == "" { + value = fallback + } + if len(value) > limit { + return value[:limit] + } + return value +} + +const deviceCheckScript = ` +const addon = require(process.env.SUB2API_DEVICECHECK_MODULE); +const signals = JSON.parse(process.env.SUB2API_ATTESTATION_SIGNALS); +const bundleID = process.env.SUB2API_ATTESTATION_BUNDLE_ID; + +function head(major, value) { + if (value < 24) return Buffer.from([major + value]); + if (value <= 255) return Buffer.from([major + 24, value]); + if (value <= 65535) { + const out = Buffer.allocUnsafe(3); + out[0] = major + 25; + out.writeUInt16BE(value, 1); + return out; + } + const out = Buffer.allocUnsafe(5); + out[0] = major + 26; + out.writeUInt32BE(value, 1); + return out; +} +function uint(value) { return head(0, value); } +function text(value) { + const body = Buffer.from(value, "utf8"); + return Buffer.concat([head(96, body.length), body]); +} +function float(value) { + if (Number.isSafeInteger(value) && value >= 0) return uint(value); + const out = Buffer.allocUnsafe(9); + out[0] = 251; + out.writeDoubleBE(value, 1); + return out; +} +function array(values) { return Buffer.concat([head(128, values.length), ...values]); } +function map(entries) { + return Buffer.concat([head(160, entries.length), ...entries.flatMap(([key, value]) => [uint(key), value])]); +} +function field(key, value) { return Buffer.concat([text(key), text(value)]); } +function base64url(value) { + return value.toString("base64").replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/u, ""); +} + +(async () => { + const result = await addon.generateToken(); + if (!result || !result.supported) throw new Error("DeviceCheck is not supported on this Mac"); + if (!result.tokenBase64) throw new Error("DeviceCheck returned no token"); + const fingerprint = map([ + [0, uint(signals.schemaVersion)], + [1, array(signals.preferredLanguages.map(text))], + [2, text(signals.locale)], + [3, text(signals.timezone)], + [4, uint(signals.screenSizeSum)], + [5, float(signals.screenScale)], + [6, text(signals.appSessionId)] + ]); + const fields = [ + field("token", result.tokenBase64), + field("bundle_id", bundleID), + Buffer.concat([text("f"), head(64, fingerprint.length), fingerprint]) + ]; + if (result.latencyMs != null) { + fields.push(Buffer.concat([text("t"), float(result.latencyMs)])); + } + const token = "v1." + base64url(Buffer.concat([Buffer.from([160 + fields.length]), ...fields])); + process.stdout.write(JSON.stringify({v: 1, s: 0, t: token})); +})().catch((error) => { + process.stderr.write(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; +});` diff --git a/backend/internal/platform/liveattestation/attestation_unsupported.go b/backend/internal/platform/liveattestation/attestation_unsupported.go new file mode 100644 index 0000000000..176561200a --- /dev/null +++ b/backend/internal/platform/liveattestation/attestation_unsupported.go @@ -0,0 +1,15 @@ +//go:build !darwin + +package liveattestation + +import "context" + +type unsupportedProvider struct{} + +func NewProvider() Provider { + return unsupportedProvider{} +} + +func (unsupportedProvider) Generate(context.Context) (string, error) { + return "", ErrUnsupportedPlatform +} diff --git a/backend/internal/platform/liveattestation/attestation_unsupported_test.go b/backend/internal/platform/liveattestation/attestation_unsupported_test.go new file mode 100644 index 0000000000..24577a2635 --- /dev/null +++ b/backend/internal/platform/liveattestation/attestation_unsupported_test.go @@ -0,0 +1,16 @@ +//go:build !darwin + +package liveattestation + +import ( + "context" + "errors" + "testing" +) + +func TestUnsupportedProviderReturnsExplicitPlatformError(t *testing.T) { + _, err := NewProvider().Generate(context.Background()) + if !errors.Is(err, ErrUnsupportedPlatform) { + t.Fatalf("Generate() error = %v, want ErrUnsupportedPlatform", err) + } +} diff --git a/backend/internal/repository/gateway_cache.go b/backend/internal/repository/gateway_cache.go index e17a135d52..d19c3cd512 100644 --- a/backend/internal/repository/gateway_cache.go +++ b/backend/internal/repository/gateway_cache.go @@ -148,6 +148,7 @@ func (c *gatewayCache) SaveLiveCall(ctx context.Context, record *service.LiveCal "user_agent": record.UserAgent, "ip_address": record.IPAddress, "inbound_endpoint": record.InboundEndpoint, + "attestation": record.AttestationCiphertext, } key := liveCallKey(record.CallHash) pipe := c.rdb.TxPipeline() @@ -172,22 +173,23 @@ func (c *gatewayCache) GetLiveCall(ctx context.Context, callHash string) (*servi createdAt := time.UnixMilli(parseInt("created_at")) expiresAt := time.UnixMilli(parseInt("expires_at")) return &service.LiveCallRecord{ - CallID: values["call_id"], - CallHash: callHash, - AccountID: parseInt("account_id"), - APIKeyID: parseInt("api_key_id"), - UserID: parseInt("user_id"), - GroupID: parseInt("group_id"), - SubscriptionID: parseInt("subscription_id"), - LeaseID: values["lease_id"], - Model: values["model"], - CreatedAt: createdAt, - ExpiresAt: expiresAt, - Controller: values["controller"], - ControllerOwner: values["controller_owner"], - UserAgent: values["user_agent"], - IPAddress: values["ip_address"], - InboundEndpoint: values["inbound_endpoint"], + CallID: values["call_id"], + CallHash: callHash, + AccountID: parseInt("account_id"), + APIKeyID: parseInt("api_key_id"), + UserID: parseInt("user_id"), + GroupID: parseInt("group_id"), + SubscriptionID: parseInt("subscription_id"), + LeaseID: values["lease_id"], + Model: values["model"], + CreatedAt: createdAt, + ExpiresAt: expiresAt, + Controller: values["controller"], + ControllerOwner: values["controller_owner"], + UserAgent: values["user_agent"], + IPAddress: values["ip_address"], + InboundEndpoint: values["inbound_endpoint"], + AttestationCiphertext: values["attestation"], }, nil } diff --git a/backend/internal/repository/gateway_cache_live_test.go b/backend/internal/repository/gateway_cache_live_test.go index ad2b4e804b..7d14bfdd73 100644 --- a/backend/internal/repository/gateway_cache_live_test.go +++ b/backend/internal/repository/gateway_cache_live_test.go @@ -17,17 +17,18 @@ func TestGatewayCacheLiveCallIdentityAndController(t *testing.T) { cache := NewGatewayCache(client).(service.LiveCallStore) otherInstance := NewGatewayCache(client).(service.LiveCallStore) record := &service.LiveCallRecord{ - CallID: "call_secret", - CallHash: HashLiveCallID("call_secret"), - AccountID: 11, - APIKeyID: 22, - UserID: 33, - GroupID: 44, - LeaseID: "lease", - Model: "gpt-live-test", - CreatedAt: time.Now(), - ExpiresAt: time.Now().Add(time.Hour), - Controller: service.LiveControllerPending, + CallID: "call_secret", + CallHash: HashLiveCallID("call_secret"), + AccountID: 11, + APIKeyID: 22, + UserID: 33, + GroupID: 44, + LeaseID: "lease", + Model: "gpt-live-test", + AttestationCiphertext: "encrypted-attestation", + CreatedAt: time.Now(), + ExpiresAt: time.Now().Add(time.Hour), + Controller: service.LiveControllerPending, } require.NoError(t, cache.SaveLiveCall(context.Background(), record, time.Hour)) @@ -35,6 +36,7 @@ func TestGatewayCacheLiveCallIdentityAndController(t *testing.T) { require.NoError(t, err) require.Equal(t, record.CallID, loaded.CallID) require.Equal(t, record.AccountID, loaded.AccountID) + require.Equal(t, record.AttestationCiphertext, loaded.AttestationCiphertext) claimed, err := cache.ClaimLiveController(context.Background(), record.CallHash, service.LiveControllerObserver, "observer-1") require.NoError(t, err) diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index 5cb43f7baa..c06a989908 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -19,6 +19,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/ip" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" "github.com/Wei-Shaw/sub2api/internal/pkg/openai" + "github.com/Wei-Shaw/sub2api/internal/platform/liveattestation" "github.com/Wei-Shaw/sub2api/internal/util/responseheaders" "github.com/cespare/xxhash/v2" "github.com/gin-gonic/gin" @@ -410,6 +411,8 @@ type OpenAIGatewayService struct { balanceNotifyService *BalanceNotifyService settingService *SettingService userPlatformQuotaRepo UserPlatformQuotaRepository + liveAttestation liveattestation.Provider + liveAttestationCipher SecretEncryptor openaiWSPoolOnce sync.Once openaiWSStateStoreOnce sync.Once @@ -499,6 +502,8 @@ func NewOpenAIGatewayService( balanceNotifyService: balanceNotifyService, settingService: settingService, userPlatformQuotaRepo: userPlatformQuotaRepo, + liveAttestation: liveattestation.NewProvider(), + liveAttestationCipher: newLiveAttestationCipher(cfg), responseHeaderFilter: compileResponseHeaderFilter(cfg), codexSnapshotThrottle: newAccountWriteThrottle(openAICodexSnapshotPersistMinInterval), openaiModelTransient: newOpenAIAccountModelTransientState(openAIModelTransientDefaultMax), diff --git a/backend/internal/service/openai_live.go b/backend/internal/service/openai_live.go index 92ba900b31..1d4df6b36b 100644 --- a/backend/internal/service/openai_live.go +++ b/backend/internal/service/openai_live.go @@ -134,6 +134,10 @@ func (s *OpenAIGatewayService) CreateLiveCall( if err != nil { return nil, err } + attestation, attestationCiphertext, err := s.prepareLiveAttestation(ctx) + if err != nil { + return nil, err + } excluded := make(map[int64]struct{}) var lastErr error @@ -184,7 +188,7 @@ func (s *OpenAIGatewayService) CreateLiveCall( return nil, ErrLiveConcurrencyFull } - created, createErr := s.createUpstreamLiveCall(ctx, account, request) + created, createErr := s.createUpstreamLiveCall(ctx, account, request, attestation) selection.ReleaseFunc() if createErr != nil { s.releaseLiveLease(account.ID, identity.UserID, identity.APIKeyID, leaseID) @@ -202,21 +206,22 @@ func (s *OpenAIGatewayService) CreateLiveCall( model = "gpt-live" } record := &LiveCallRecord{ - CallID: created.CallID, - CallHash: hashLiveCallID(created.CallID), - AccountID: account.ID, - APIKeyID: identity.APIKeyID, - UserID: identity.UserID, - GroupID: liveGroupID(identity.GroupID), - SubscriptionID: liveGroupID(identity.SubscriptionID), - LeaseID: leaseID, - Model: model, - CreatedAt: now, - ExpiresAt: now.Add(s.liveMaxSessionDuration()), - Controller: LiveControllerPending, - UserAgent: identity.UserAgent, - IPAddress: identity.IPAddress, - InboundEndpoint: identity.InboundEndpoint, + CallID: created.CallID, + CallHash: hashLiveCallID(created.CallID), + AccountID: account.ID, + APIKeyID: identity.APIKeyID, + UserID: identity.UserID, + GroupID: liveGroupID(identity.GroupID), + SubscriptionID: liveGroupID(identity.SubscriptionID), + LeaseID: leaseID, + Model: model, + CreatedAt: now, + ExpiresAt: now.Add(s.liveMaxSessionDuration()), + Controller: LiveControllerPending, + UserAgent: identity.UserAgent, + IPAddress: identity.IPAddress, + InboundEndpoint: identity.InboundEndpoint, + AttestationCiphertext: attestationCiphertext, } mappingTTL := s.liveMaxSessionDuration() + 5*time.Minute if saveErr := store.SaveLiveCall(ctx, record, mappingTTL); saveErr != nil { @@ -250,6 +255,7 @@ func (s *OpenAIGatewayService) createUpstreamLiveCall( ctx context.Context, account *Account, request *LiveCallRequest, + attestation string, ) (*LiveCallCreated, error) { token, _, err := s.GetAccessToken(ctx, account) if err != nil { @@ -288,6 +294,7 @@ func (s *OpenAIGatewayService) createUpstreamLiveCall( } upstreamReq.Header.Set("Content-Type", "application/json") upstreamReq.Header.Set("Accept", "application/sdp") + upstreamReq.Header.Set(liveAttestationHeader, attestation) applyLiveUpstreamIdentityHeaders(upstreamReq.Header) resp, err := s.httpUpstream.Do(upstreamReq, resolveAccountProxyURL(account), account.ID, account.Concurrency) @@ -399,7 +406,11 @@ func applyLiveUpstreamIdentityHeaders(headers http.Header) { headers.Del("OpenAI-Beta") } -func (s *OpenAIGatewayService) liveSidebandHeaders(ctx context.Context, account *Account) (http.Header, error) { +func (s *OpenAIGatewayService) liveSidebandHeaders( + ctx context.Context, + account *Account, + record *LiveCallRecord, +) (http.Header, error) { token, _, err := s.GetAccessToken(ctx, account) if err != nil { return nil, err @@ -411,6 +422,11 @@ func (s *OpenAIGatewayService) liveSidebandHeaders(ctx context.Context, account if err := resolveAndSetOpenAIChatGPTAccountHeaders(ctx, s.accountRepo, headers, account); err != nil { return nil, err } + attestation, err := s.decryptLiveAttestation(record) + if err != nil { + return nil, err + } + headers.Set(liveAttestationHeader, attestation) applyLiveUpstreamIdentityHeaders(headers) return headers, nil } @@ -423,7 +439,7 @@ func (s *OpenAIGatewayService) dialLiveSideband(ctx context.Context, record *Liv if account == nil || !account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityLive) { return nil, ErrLiveUnavailable } - headers, err := s.liveSidebandHeaders(ctx, account) + headers, err := s.liveSidebandHeaders(ctx, account, record) if err != nil { return nil, err } diff --git a/backend/internal/service/openai_live_attestation.go b/backend/internal/service/openai_live_attestation.go new file mode 100644 index 0000000000..bc22eadec4 --- /dev/null +++ b/backend/internal/service/openai_live_attestation.go @@ -0,0 +1,110 @@ +package service + +import ( + "context" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "io" + "strings" + + "github.com/Wei-Shaw/sub2api/internal/config" +) + +const liveAttestationHeader = "x-oai-attestation" + +type liveAttestationAES struct { + key [32]byte +} + +func newLiveAttestationCipher(cfg *config.Config) SecretEncryptor { + if cfg == nil || strings.TrimSpace(cfg.JWT.Secret) == "" { + return nil + } + return &liveAttestationAES{ + key: sha256.Sum256([]byte("sub2api/live-attestation/v1\x00" + cfg.JWT.Secret)), + } +} + +func (c *liveAttestationAES) Encrypt(plaintext string) (string, error) { + block, err := aes.NewCipher(c.key[:]) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", fmt.Errorf("generate Live attestation nonce: %w", err) + } + encrypted := gcm.Seal(nonce, nonce, []byte(plaintext), nil) + return base64.RawStdEncoding.EncodeToString(encrypted), nil +} + +func (c *liveAttestationAES) Decrypt(ciphertext string) (string, error) { + encrypted, err := base64.RawStdEncoding.DecodeString(ciphertext) + if err != nil { + return "", fmt.Errorf("decode Live attestation: %w", err) + } + block, err := aes.NewCipher(c.key[:]) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + if len(encrypted) < gcm.NonceSize() { + return "", errors.New("encrypted Live attestation is too short") + } + plaintext, err := gcm.Open(nil, encrypted[:gcm.NonceSize()], encrypted[gcm.NonceSize():], nil) + if err != nil { + return "", fmt.Errorf("decrypt Live attestation: %w", err) + } + return string(plaintext), nil +} + +func (s *OpenAIGatewayService) prepareLiveAttestation(ctx context.Context) (string, string, error) { + if s == nil || s.liveAttestation == nil { + return "", "", &LiveAttestationUnavailableError{ + Reason: "Sub2API has no platform DeviceCheck provider", + } + } + if s.liveAttestationCipher == nil { + return "", "", &LiveAttestationUnavailableError{ + Reason: "JWT secret is required to protect the Sideband attestation", + } + } + header, err := s.liveAttestation.Generate(ctx) + if err != nil { + return "", "", &LiveAttestationUnavailableError{Reason: err.Error()} + } + ciphertext, err := s.liveAttestationCipher.Encrypt(header) + if err != nil { + return "", "", &LiveAttestationUnavailableError{ + Reason: "failed to protect the generated DeviceCheck attestation", + } + } + return header, ciphertext, nil +} + +func (s *OpenAIGatewayService) decryptLiveAttestation(record *LiveCallRecord) (string, error) { + if record == nil || strings.TrimSpace(record.AttestationCiphertext) == "" || s.liveAttestationCipher == nil { + return "", &LiveAttestationUnavailableError{ + Reason: "the Live call has no reusable DeviceCheck attestation", + } + } + header, err := s.liveAttestationCipher.Decrypt(record.AttestationCiphertext) + if err != nil { + return "", &LiveAttestationUnavailableError{ + Reason: "the Live call DeviceCheck attestation cannot be decrypted on this instance", + } + } + return header, nil +} diff --git a/backend/internal/service/openai_live_lifecycle_test.go b/backend/internal/service/openai_live_lifecycle_test.go index e5e071bf4b..40eee7885e 100644 --- a/backend/internal/service/openai_live_lifecycle_test.go +++ b/backend/internal/service/openai_live_lifecycle_test.go @@ -11,6 +11,7 @@ import ( "testing" "time" + "github.com/Wei-Shaw/sub2api/internal/config" coderws "github.com/coder/websocket" "github.com/stretchr/testify/require" ) @@ -347,6 +348,12 @@ func TestProxyLiveSidebandForwardsTextAndBinary(t *testing.T) { ExpiresAt: time.Now().Add(time.Minute), Controller: LiveControllerPending, } + attestationCipher := newLiveAttestationCipher(&config.Config{ + JWT: config.JWTConfig{Secret: "live-sideband-test-secret"}, + }) + var err error + record.AttestationCiphertext, err = attestationCipher.Encrypt(`{"v":1,"s":0,"t":"v1.sideband"}`) + require.NoError(t, err) store := &liveTestStore{} require.NoError(t, store.SaveLiveCall(context.Background(), record, time.Hour)) upstream := newLiveTestFrameConn() @@ -355,6 +362,7 @@ func TestProxyLiveSidebandForwardsTextAndBinary(t *testing.T) { accountRepo: &liveTestAccountRepo{account: account}, cache: store, openaiWSPassthroughDialer: dialer, + liveAttestationCipher: attestationCipher, } proxyResult := make(chan error, 1) server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { @@ -403,6 +411,7 @@ func TestProxyLiveSidebandForwardsTextAndBinary(t *testing.T) { require.Equal(t, "wss://chatgpt.com/backend-api/codex/call_proxy", dialer.url) require.Equal(t, "Bearer test-access-token", dialer.headers.Get("Authorization")) require.Equal(t, "acct_test", dialer.headers.Get("Chatgpt-Account-Id")) + require.Equal(t, `{"v":1,"s":0,"t":"v1.sideband"}`, dialer.headers.Get(liveAttestationHeader)) upstream.reads <- liveTestFrame{err: coderws.CloseError{Code: coderws.StatusNormalClosure}} require.ErrorIs(t, <-proxyResult, ErrLiveCallNotFound) } diff --git a/backend/internal/service/openai_live_test.go b/backend/internal/service/openai_live_test.go index 30845eb45b..60cf2f0d77 100644 --- a/backend/internal/service/openai_live_test.go +++ b/backend/internal/service/openai_live_test.go @@ -21,6 +21,15 @@ type liveHTTPUpstreamStub struct { body []byte } +type liveAttestationStub struct { + header string + err error +} + +func (s liveAttestationStub) Generate(context.Context) (string, error) { + return s.header, s.err +} + func (s *liveHTTPUpstreamStub) Do( request *http.Request, _ string, @@ -106,7 +115,7 @@ func TestCreateUpstreamLiveCallPreservesSession(t *testing.T) { created, err := service.createUpstreamLiveCall(context.Background(), account, &LiveCallRequest{ SDP: "v=offer\r\n", Session: session, - }) + }, `{"v":1,"s":0,"t":"v1.test"}`) require.NoError(t, err) require.Equal(t, "call_test", created.CallID) require.Equal(t, []byte("v=0\r\n"), created.SDP) @@ -121,6 +130,7 @@ func TestCreateUpstreamLiveCallPreservesSession(t *testing.T) { require.Equal(t, "Bearer test-access-token", upstream.request.Header.Get("Authorization")) require.Equal(t, "acct_test", upstream.request.Header.Get("Chatgpt-Account-Id")) require.Equal(t, "quicksilver=v2", upstream.request.Header.Get("OpenAI-Alpha")) + require.Equal(t, `{"v":1,"s":0,"t":"v1.test"}`, upstream.request.Header.Get(liveAttestationHeader)) require.NotEmpty(t, upstream.request.Header.Get("Session-Id")) require.NotEmpty(t, upstream.request.Header.Get("Thread-Id")) require.Empty(t, upstream.request.Header.Get("OpenAI-Beta")) @@ -128,6 +138,51 @@ func TestCreateUpstreamLiveCallPreservesSession(t *testing.T) { require.True(t, HTTPUpstreamRedirectsDisabled(upstream.request.Context())) } +func TestLiveAttestationCipherRoundTripAndRejectsOtherInstanceKey(t *testing.T) { + first := newLiveAttestationCipher(&config.Config{ + JWT: config.JWTConfig{Secret: "first-live-secret"}, + }) + second := newLiveAttestationCipher(&config.Config{ + JWT: config.JWTConfig{Secret: "second-live-secret"}, + }) + require.NotNil(t, first) + require.NotNil(t, second) + + ciphertext, err := first.Encrypt(`{"v":1,"s":0,"t":"v1.opaque"}`) + require.NoError(t, err) + require.NotContains(t, ciphertext, "opaque") + + plaintext, err := first.Decrypt(ciphertext) + require.NoError(t, err) + require.Equal(t, `{"v":1,"s":0,"t":"v1.opaque"}`, plaintext) + + _, err = second.Decrypt(ciphertext) + require.Error(t, err) +} + +func TestPrepareLiveAttestationEncryptsHeaderAndReturnsExplicitProviderError(t *testing.T) { + cipher := newLiveAttestationCipher(&config.Config{ + JWT: config.JWTConfig{Secret: "live-attestation-test-secret"}, + }) + service := &OpenAIGatewayService{ + liveAttestation: liveAttestationStub{header: `{"v":1,"s":0,"t":"v1.test"}`}, + liveAttestationCipher: cipher, + } + header, ciphertext, err := service.prepareLiveAttestation(context.Background()) + require.NoError(t, err) + require.Equal(t, `{"v":1,"s":0,"t":"v1.test"}`, header) + require.NotContains(t, ciphertext, "v1.test") + decrypted, err := cipher.Decrypt(ciphertext) + require.NoError(t, err) + require.Equal(t, header, decrypted) + + service.liveAttestation = liveAttestationStub{err: errors.New("macOS app missing")} + _, _, err = service.prepareLiveAttestation(context.Background()) + var unavailable *LiveAttestationUnavailableError + require.ErrorAs(t, err, &unavailable) + require.Contains(t, unavailable.Error(), "macOS app missing") +} + func TestLiveMaxSessionDurationDefaultsAndOverrides(t *testing.T) { require.Equal(t, defaultLiveMaxSessionDuration, (&OpenAIGatewayService{}).liveMaxSessionDuration()) require.Equal( diff --git a/backend/internal/service/openai_live_types.go b/backend/internal/service/openai_live_types.go index 1ada2f362a..8ef586f217 100644 --- a/backend/internal/service/openai_live_types.go +++ b/backend/internal/service/openai_live_types.go @@ -22,6 +22,17 @@ var ( ErrLiveControllerChanged = errors.New("live controller changed") ) +type LiveAttestationUnavailableError struct { + Reason string +} + +func (e *LiveAttestationUnavailableError) Error() string { + if e == nil || e.Reason == "" { + return "Live attestation is unavailable" + } + return "Live attestation is unavailable: " + e.Reason +} + // LiveCallRequest 是两个下游创建协议归一后的请求。Session 不做结构改写。 type LiveCallRequest struct { SDP string `json:"sdp"` @@ -55,6 +66,8 @@ type LiveCallRecord struct { UserAgent string IPAddress string InboundEndpoint string + // AttestationCiphertext 仅用于让同一会话的 Sideband 复用创建时的证明。 + AttestationCiphertext string } type LiveCallCreated struct { diff --git a/frontend/src/i18n/locales/en/admin/overview.ts b/frontend/src/i18n/locales/en/admin/overview.ts index 6f1eacd3b4..1859847345 100644 --- a/frontend/src/i18n/locales/en/admin/overview.ts +++ b/frontend/src/i18n/locales/en/admin/overview.ts @@ -1098,7 +1098,7 @@ export default { openaiLive: { title: 'OpenAI Live', allow: 'Allow Live access', - hint: 'When enabled, API keys in this OpenAI group can create and control Live voice sessions. Disabled by default.' + hint: 'When enabled, API keys in this OpenAI group can create and control Live voice sessions. Disabled by default. The Sub2API server must run on macOS with the official ChatGPT app installed; client platforms are unrestricted.' }, invalidRequestFallback: { title: 'Invalid Request Fallback Group', diff --git a/frontend/src/i18n/locales/zh/admin/overview.ts b/frontend/src/i18n/locales/zh/admin/overview.ts index d76938a050..e1234fb4bc 100644 --- a/frontend/src/i18n/locales/zh/admin/overview.ts +++ b/frontend/src/i18n/locales/zh/admin/overview.ts @@ -1096,7 +1096,7 @@ export default { openaiLive: { title: 'OpenAI Live', allow: '允许访问 Live', - hint: '启用后,此 OpenAI 分组的 API Key 可以创建并控制 Live 语音会话。默认关闭。' + hint: '启用后,此 OpenAI 分组的 API Key 可以创建并控制 Live 语音会话。默认关闭。运行 Sub2API 的服务端必须是 macOS,并安装官方 ChatGPT App;客户端平台不受限制。' }, invalidRequestFallback: { title: '无效请求兜底分组',