From 988d4b577e818b3fdc4c40bae954fe1e49c6a2d5 Mon Sep 17 00:00:00 2001 From: song Date: Sat, 25 Jul 2026 11:23:13 +0800 Subject: [PATCH] feat(openai): add macOS Live attestation --- backend/internal/config/config.go | 1 + backend/internal/handler/openai_live.go | 5 + backend/internal/handler/openai_live_test.go | 14 + .../platform/liveattestation/attestation.go | 16 + .../liveattestation/attestation_darwin.go | 280 ++++++++++++++++++ .../attestation_unsupported.go | 15 + .../attestation_unsupported_test.go | 16 + backend/internal/repository/gateway_cache.go | 34 ++- .../repository/gateway_cache_live_test.go | 24 +- .../service/openai_gateway_service.go | 5 + backend/internal/service/openai_live.go | 52 ++-- .../service/openai_live_attestation.go | 110 +++++++ .../service/openai_live_lifecycle_test.go | 9 + backend/internal/service/openai_live_test.go | 57 +++- backend/internal/service/openai_live_types.go | 13 + .../src/i18n/locales/en/admin/overview.ts | 2 +- .../src/i18n/locales/zh/admin/overview.ts | 2 +- 17 files changed, 607 insertions(+), 48 deletions(-) create mode 100644 backend/internal/platform/liveattestation/attestation.go create mode 100644 backend/internal/platform/liveattestation/attestation_darwin.go create mode 100644 backend/internal/platform/liveattestation/attestation_unsupported.go create mode 100644 backend/internal/platform/liveattestation/attestation_unsupported_test.go create mode 100644 backend/internal/service/openai_live_attestation.go diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 34f02f989..a62c068f2 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 466d84d68..b2bad0e0e 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 dde35f9f9..9df49654c 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 000000000..bfcb61dc3 --- /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 000000000..9c0c560b8 --- /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 000000000..176561200 --- /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 000000000..24577a263 --- /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 e17a135d5..d19c3cd51 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 ad2b4e804..7d14bfdd7 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 5cb43f7ba..c06a98990 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 92ba900b3..1d4df6b36 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 000000000..bc22eadec --- /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 e5e071bf4..40eee7885 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 30845eb45..60cf2f0d7 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 1ada2f362..8ef586f21 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 6f1eacd3b..185984734 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 d76938a05..e1234fb4b 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: '无效请求兜底分组',