Files
sub2api/backend/internal/platform/liveattestation/attestation_darwin.go
T
李建琦 6d655c9903
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
Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
2026-08-21 18:30:13 +08:00

295 lines
9.0 KiB
Go

//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;
});`