attestation_darwin.go 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294
  1. //go:build darwin
  2. package liveattestation
  3. import (
  4. "bytes"
  5. "context"
  6. "encoding/json"
  7. "errors"
  8. "fmt"
  9. "os"
  10. "os/exec"
  11. "path/filepath"
  12. "runtime"
  13. "strings"
  14. "time"
  15. "github.com/google/uuid"
  16. )
  17. const (
  18. chatGPTApplicationPath = "/Applications/ChatGPT.app"
  19. attestationTimeout = 5 * time.Second
  20. )
  21. type darwinProvider struct {
  22. appSessionID string
  23. appPaths []string
  24. }
  25. type deviceSignals struct {
  26. SchemaVersion int `json:"schemaVersion"`
  27. PreferredLanguages []string `json:"preferredLanguages"`
  28. Locale string `json:"locale"`
  29. Timezone string `json:"timezone"`
  30. ScreenSizeSum int `json:"screenSizeSum"`
  31. ScreenScale float64 `json:"screenScale"`
  32. AppSessionID string `json:"appSessionId"`
  33. }
  34. type macOSSignals struct {
  35. Locale string `json:"locale"`
  36. Languages []string `json:"languages"`
  37. Timezone string `json:"timezone"`
  38. Width float64 `json:"width"`
  39. Height float64 `json:"height"`
  40. Scale float64 `json:"scale"`
  41. }
  42. func NewProvider() Provider {
  43. paths := []string{chatGPTApplicationPath}
  44. if home, err := os.UserHomeDir(); err == nil && strings.TrimSpace(home) != "" {
  45. paths = append(paths, filepath.Join(home, "Applications", "ChatGPT.app"))
  46. }
  47. return &darwinProvider{
  48. appSessionID: uuid.NewString(),
  49. appPaths: paths,
  50. }
  51. }
  52. func (p *darwinProvider) Check(ctx context.Context) error {
  53. checkCtx, cancel := context.WithTimeout(ctx, attestationTimeout)
  54. defer cancel()
  55. _, _, _, err := p.resolveRuntime(checkCtx)
  56. return err
  57. }
  58. func (p *darwinProvider) resolveRuntime(ctx context.Context) (string, string, string, error) {
  59. if runtime.GOARCH != "arm64" {
  60. return "", "", "", errors.New("live attestation currently requires Apple Silicon; Intel macOS is not supported")
  61. }
  62. appPath, err := p.findApplication()
  63. if err != nil {
  64. return "", "", "", err
  65. }
  66. resourcesPath := filepath.Join(appPath, "Contents", "Resources")
  67. nodePath := filepath.Join(resourcesPath, "cua_node", "bin", "node")
  68. modulePath := filepath.Join(resourcesPath, "native", "devicecheck.node")
  69. for filePath, label := range map[string]string{
  70. nodePath: "bundled Node.js runtime",
  71. modulePath: "DeviceCheck native module",
  72. } {
  73. if info, statErr := os.Stat(filePath); statErr != nil || info.IsDir() {
  74. return "", "", "", fmt.Errorf("%w: ChatGPT app is missing its %s", ErrChatGPTAppMissing, label)
  75. }
  76. }
  77. bundleID, err := readBundleIdentifier(ctx, appPath)
  78. if err != nil {
  79. return "", "", "", err
  80. }
  81. return nodePath, modulePath, bundleID, nil
  82. }
  83. func (p *darwinProvider) Generate(ctx context.Context) (string, error) {
  84. runCtx, cancel := context.WithTimeout(ctx, attestationTimeout)
  85. defer cancel()
  86. nodePath, modulePath, bundleID, err := p.resolveRuntime(runCtx)
  87. if err != nil {
  88. return "", err
  89. }
  90. signals, err := p.readSignals(runCtx)
  91. if err != nil {
  92. return "", err
  93. }
  94. signalsJSON, err := json.Marshal(signals)
  95. if err != nil {
  96. return "", fmt.Errorf("encode Live attestation signals: %w", err)
  97. }
  98. command := exec.CommandContext(runCtx, nodePath, "-e", deviceCheckScript)
  99. command.Env = []string{
  100. "PATH=/usr/bin:/bin",
  101. "SUB2API_DEVICECHECK_MODULE=" + modulePath,
  102. "SUB2API_ATTESTATION_BUNDLE_ID=" + bundleID,
  103. "SUB2API_ATTESTATION_SIGNALS=" + string(signalsJSON),
  104. }
  105. var stdout bytes.Buffer
  106. var stderr bytes.Buffer
  107. command.Stdout = &stdout
  108. command.Stderr = &stderr
  109. if err := command.Run(); err != nil {
  110. if errors.Is(runCtx.Err(), context.DeadlineExceeded) {
  111. return "", errors.New("ChatGPT DeviceCheck token generation timed out")
  112. }
  113. reason := strings.TrimSpace(stderr.String())
  114. if len(reason) > 240 {
  115. reason = reason[:240]
  116. }
  117. if reason == "" {
  118. reason = err.Error()
  119. }
  120. return "", fmt.Errorf("ChatGPT DeviceCheck token generation failed: %s", reason)
  121. }
  122. header := strings.TrimSpace(stdout.String())
  123. if len(header) < 20 || len(header) > 16*1024 || !json.Valid([]byte(header)) {
  124. return "", errors.New("ChatGPT DeviceCheck returned a malformed attestation")
  125. }
  126. return header, nil
  127. }
  128. func (p *darwinProvider) findApplication() (string, error) {
  129. for _, appPath := range p.appPaths {
  130. info, err := os.Stat(appPath)
  131. if err == nil && info.IsDir() {
  132. return appPath, nil
  133. }
  134. }
  135. return "", ErrChatGPTAppMissing
  136. }
  137. func readBundleIdentifier(ctx context.Context, appPath string) (string, error) {
  138. infoPlist := filepath.Join(appPath, "Contents", "Info.plist")
  139. output, err := exec.CommandContext(
  140. ctx,
  141. "/usr/bin/plutil",
  142. "-extract",
  143. "CFBundleIdentifier",
  144. "raw",
  145. infoPlist,
  146. ).Output()
  147. if err != nil {
  148. return "", fmt.Errorf("%w: cannot read its bundle identifier", ErrChatGPTAppMissing)
  149. }
  150. bundleID := strings.TrimSpace(string(output))
  151. if !strings.HasPrefix(bundleID, "com.openai.") {
  152. return "", errors.New("the installed ChatGPT app has an unexpected bundle identifier")
  153. }
  154. return bundleID, nil
  155. }
  156. func (p *darwinProvider) readSignals(ctx context.Context) (deviceSignals, error) {
  157. const script = `ObjC.import("Foundation"); ObjC.import("AppKit");
  158. const screen = $.NSScreen.mainScreen;
  159. const frame = screen.frame;
  160. JSON.stringify({
  161. locale: ObjC.unwrap($.NSLocale.currentLocale.localeIdentifier),
  162. languages: ObjC.deepUnwrap($.NSLocale.preferredLanguages),
  163. timezone: ObjC.unwrap($.NSTimeZone.localTimeZone.name),
  164. width: Number(frame.size.width),
  165. height: Number(frame.size.height),
  166. scale: Number(screen.backingScaleFactor)
  167. })`
  168. output, err := exec.CommandContext(ctx, "/usr/bin/osascript", "-l", "JavaScript", "-e", script).Output()
  169. if err != nil {
  170. return deviceSignals{}, fmt.Errorf("read macOS signals for Live attestation: %w", err)
  171. }
  172. var values macOSSignals
  173. if err := json.Unmarshal(output, &values); err != nil {
  174. return deviceSignals{}, fmt.Errorf("decode macOS signals for Live attestation: %w", err)
  175. }
  176. locale := truncateSignal(values.Locale, 64, "unknown")
  177. languages := values.Languages
  178. if len(languages) == 0 {
  179. languages = []string{locale}
  180. }
  181. if len(languages) > 16 {
  182. languages = languages[:16]
  183. }
  184. for index := range languages {
  185. languages[index] = truncateSignal(languages[index], 64, locale)
  186. }
  187. scale := values.Scale
  188. if scale <= 0 {
  189. scale = 1
  190. }
  191. return deviceSignals{
  192. SchemaVersion: 1,
  193. PreferredLanguages: languages,
  194. Locale: locale,
  195. Timezone: truncateSignal(values.Timezone, 64, "unknown"),
  196. ScreenSizeSum: max(0, int(values.Width+values.Height+0.5)),
  197. ScreenScale: scale,
  198. AppSessionID: truncateSignal(p.appSessionID, 128, uuid.NewString()),
  199. }, nil
  200. }
  201. func truncateSignal(value string, limit int, fallback string) string {
  202. value = strings.TrimSpace(value)
  203. if value == "" {
  204. value = fallback
  205. }
  206. if len(value) > limit {
  207. return value[:limit]
  208. }
  209. return value
  210. }
  211. const deviceCheckScript = `
  212. const addon = require(process.env.SUB2API_DEVICECHECK_MODULE);
  213. const signals = JSON.parse(process.env.SUB2API_ATTESTATION_SIGNALS);
  214. const bundleID = process.env.SUB2API_ATTESTATION_BUNDLE_ID;
  215. function head(major, value) {
  216. if (value < 24) return Buffer.from([major + value]);
  217. if (value <= 255) return Buffer.from([major + 24, value]);
  218. if (value <= 65535) {
  219. const out = Buffer.allocUnsafe(3);
  220. out[0] = major + 25;
  221. out.writeUInt16BE(value, 1);
  222. return out;
  223. }
  224. const out = Buffer.allocUnsafe(5);
  225. out[0] = major + 26;
  226. out.writeUInt32BE(value, 1);
  227. return out;
  228. }
  229. function uint(value) { return head(0, value); }
  230. function text(value) {
  231. const body = Buffer.from(value, "utf8");
  232. return Buffer.concat([head(96, body.length), body]);
  233. }
  234. function float(value) {
  235. if (Number.isSafeInteger(value) && value >= 0) return uint(value);
  236. const out = Buffer.allocUnsafe(9);
  237. out[0] = 251;
  238. out.writeDoubleBE(value, 1);
  239. return out;
  240. }
  241. function array(values) { return Buffer.concat([head(128, values.length), ...values]); }
  242. function map(entries) {
  243. return Buffer.concat([head(160, entries.length), ...entries.flatMap(([key, value]) => [uint(key), value])]);
  244. }
  245. function field(key, value) { return Buffer.concat([text(key), text(value)]); }
  246. function base64url(value) {
  247. return value.toString("base64").replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/u, "");
  248. }
  249. (async () => {
  250. const result = await addon.generateToken();
  251. if (!result || !result.supported) throw new Error("DeviceCheck is not supported on this Mac");
  252. if (!result.tokenBase64) throw new Error("DeviceCheck returned no token");
  253. const fingerprint = map([
  254. [0, uint(signals.schemaVersion)],
  255. [1, array(signals.preferredLanguages.map(text))],
  256. [2, text(signals.locale)],
  257. [3, text(signals.timezone)],
  258. [4, uint(signals.screenSizeSum)],
  259. [5, float(signals.screenScale)],
  260. [6, text(signals.appSessionId)]
  261. ]);
  262. const fields = [
  263. field("token", result.tokenBase64),
  264. field("bundle_id", bundleID),
  265. Buffer.concat([text("f"), head(64, fingerprint.length), fingerprint])
  266. ];
  267. if (result.latencyMs != null) {
  268. fields.push(Buffer.concat([text("t"), float(result.latencyMs)]));
  269. }
  270. const token = "v1." + base64url(Buffer.concat([Buffer.from([160 + fields.length]), ...fields]));
  271. process.stdout.write(JSON.stringify({v: 1, s: 0, t: token}));
  272. })().catch((error) => {
  273. process.stderr.write(error instanceof Error ? error.message : String(error));
  274. process.exitCode = 1;
  275. });`