Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
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 {
|
||||
Check(ctx context.Context) error
|
||||
Generate(ctx context.Context) (string, error)
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
//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) Check(ctx context.Context) error {
|
||||
checkCtx, cancel := context.WithTimeout(ctx, attestationTimeout)
|
||||
defer cancel()
|
||||
_, _, _, err := p.resolveRuntime(checkCtx)
|
||||
return err
|
||||
}
|
||||
|
||||
func (p *darwinProvider) resolveRuntime(ctx context.Context) (string, string, 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)
|
||||
}
|
||||
}
|
||||
bundleID, err := readBundleIdentifier(ctx, appPath)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
return nodePath, modulePath, bundleID, nil
|
||||
}
|
||||
|
||||
func (p *darwinProvider) Generate(ctx context.Context) (string, error) {
|
||||
runCtx, cancel := context.WithTimeout(ctx, attestationTimeout)
|
||||
defer cancel()
|
||||
nodePath, modulePath, bundleID, err := p.resolveRuntime(runCtx)
|
||||
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,19 @@
|
||||
//go:build !darwin
|
||||
|
||||
package liveattestation
|
||||
|
||||
import "context"
|
||||
|
||||
type unsupportedProvider struct{}
|
||||
|
||||
func NewProvider() Provider {
|
||||
return unsupportedProvider{}
|
||||
}
|
||||
|
||||
func (unsupportedProvider) Check(context.Context) error {
|
||||
return ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func (unsupportedProvider) Generate(context.Context) (string, error) {
|
||||
return "", ErrUnsupportedPlatform
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
//go:build !darwin
|
||||
|
||||
package liveattestation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUnsupportedProviderReturnsExplicitPlatformError(t *testing.T) {
|
||||
provider := NewProvider()
|
||||
if err := provider.Check(context.Background()); !errors.Is(err, ErrUnsupportedPlatform) {
|
||||
t.Fatalf("Check() error = %v, want ErrUnsupportedPlatform", err)
|
||||
}
|
||||
_, err := provider.Generate(context.Background())
|
||||
if !errors.Is(err, ErrUnsupportedPlatform) {
|
||||
t.Fatalf("Generate() error = %v, want ErrUnsupportedPlatform", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user