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