mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-01 15:02:58 +08:00
feat(openai): add macOS Live attestation
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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: '无效请求兜底分组',
|
||||
|
||||
Reference in New Issue
Block a user