feat(openai): add macOS Live attestation

This commit is contained in:
song
2026-07-25 11:23:13 +08:00
parent ec23716ee4
commit 988d4b577e
17 changed files with 607 additions and 48 deletions
+1
View File
@@ -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)
+5
View File
@@ -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")
@@ -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
@@ -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)
}
@@ -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;
});`
@@ -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
}
@@ -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)
}
}
+18 -16
View File
@@ -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
}
@@ -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)
@@ -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),
+34 -18
View File
@@ -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
}
@@ -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
}
@@ -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)
}
+56 -1
View File
@@ -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(
@@ -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 {
@@ -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',
@@ -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: '无效请求兜底分组',