openai_agent_identity.go 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525
  1. package service
  2. import (
  3. "context"
  4. "crypto"
  5. "crypto/ed25519"
  6. "crypto/sha512"
  7. "crypto/x509"
  8. "encoding/base64"
  9. "encoding/json"
  10. "errors"
  11. "fmt"
  12. "io"
  13. "net/http"
  14. "strings"
  15. "sync"
  16. "time"
  17. "github.com/Wei-Shaw/sub2api/internal/pkg/httpclient"
  18. "golang.org/x/crypto/curve25519"
  19. "golang.org/x/crypto/nacl/box"
  20. )
  21. const (
  22. OpenAIAuthModeAgentIdentity = "agentIdentity"
  23. agentIdentityAuthAPIBaseURL = "https://auth.openai.com/api/accounts"
  24. agentIdentityTaskRegistrationTimeout = 30 * time.Second
  25. )
  26. var openAIAgentIdentityAuthAPIBaseURL = agentIdentityAuthAPIBaseURL
  27. var agentIdentityTaskLocks sync.Map // map[int64]*sync.Mutex
  28. type agentIdentityWSConnectionInvalidator interface {
  29. InvalidateAgentIdentityWSConnections(accountID int64)
  30. }
  31. type agentIdentityKey struct {
  32. runtimeID string
  33. privateKey ed25519.PrivateKey
  34. taskID string
  35. }
  36. type agentIdentityTaskRegistrationResponse struct {
  37. TaskID string `json:"task_id"`
  38. TaskIDCamel string `json:"taskId"`
  39. EncryptedTaskID string `json:"encrypted_task_id"`
  40. EncryptedTaskIDCamel string `json:"encryptedTaskId"`
  41. }
  42. type agentIdentityTaskRecoveredError struct{}
  43. func (e *agentIdentityTaskRecoveredError) Error() string {
  44. return "agent identity task recovered"
  45. }
  46. func (a *Account) IsOpenAIAgentIdentity() bool {
  47. if a == nil || !a.IsOpenAIOAuth() {
  48. return false
  49. }
  50. return strings.EqualFold(strings.TrimSpace(a.GetCredential(openAIAuthModeCredentialKey)), OpenAIAuthModeAgentIdentity)
  51. }
  52. func agentIdentityPrivateKey(account *Account) (ed25519.PrivateKey, error) {
  53. if account == nil {
  54. return nil, errors.New("agent identity account is nil")
  55. }
  56. raw := strings.TrimSpace(account.GetCredential("agent_private_key"))
  57. if raw == "" {
  58. return nil, errors.New("agent identity private key is missing")
  59. }
  60. der, err := base64.StdEncoding.DecodeString(raw)
  61. if err != nil {
  62. return nil, errors.New("agent identity private key is not valid base64")
  63. }
  64. key, err := x509.ParsePKCS8PrivateKey(der)
  65. if err != nil {
  66. return nil, errors.New("agent identity private key is not valid PKCS#8")
  67. }
  68. privateKey, ok := key.(ed25519.PrivateKey)
  69. if !ok || len(privateKey) != ed25519.PrivateKeySize {
  70. return nil, errors.New("agent identity private key is not Ed25519")
  71. }
  72. return privateKey, nil
  73. }
  74. // ValidateOpenAIAgentIdentityPrivateKey validates the stored PKCS#8 Ed25519
  75. // form without returning or logging the key material.
  76. func ValidateOpenAIAgentIdentityPrivateKey(encoded string) error {
  77. account := &Account{Credentials: map[string]any{"agent_private_key": encoded}}
  78. _, err := agentIdentityPrivateKey(account)
  79. return err
  80. }
  81. func agentIdentityKeyFromAccount(account *Account) (agentIdentityKey, error) {
  82. privateKey, err := agentIdentityPrivateKey(account)
  83. if err != nil {
  84. return agentIdentityKey{}, err
  85. }
  86. runtimeID := strings.TrimSpace(account.GetCredential("agent_runtime_id"))
  87. if runtimeID == "" {
  88. return agentIdentityKey{}, errors.New("agent identity runtime id is missing")
  89. }
  90. return agentIdentityKey{
  91. runtimeID: runtimeID,
  92. privateKey: privateKey,
  93. taskID: strings.TrimSpace(account.GetCredential("task_id")),
  94. }, nil
  95. }
  96. func buildAgentAssertion(key agentIdentityKey, now time.Time) (string, error) {
  97. if key.runtimeID == "" || key.taskID == "" {
  98. return "", errors.New("agent identity runtime or task id is missing")
  99. }
  100. timestamp := now.UTC().Format(time.RFC3339)
  101. payload := []byte(key.runtimeID + ":" + key.taskID + ":" + timestamp)
  102. signature, err := key.privateKey.Sign(nil, payload, crypto.Hash(0))
  103. if err != nil {
  104. return "", errors.New("failed to sign agent assertion")
  105. }
  106. envelope := map[string]string{
  107. "agent_runtime_id": key.runtimeID,
  108. "task_id": key.taskID,
  109. "timestamp": timestamp,
  110. "signature": base64.StdEncoding.EncodeToString(signature),
  111. }
  112. encoded, err := json.Marshal(envelope)
  113. if err != nil {
  114. return "", errors.New("failed to serialize agent assertion")
  115. }
  116. return "AgentAssertion " + base64.RawURLEncoding.EncodeToString(encoded), nil
  117. }
  118. func signAgentTaskRegistration(key agentIdentityKey, timestamp time.Time) (string, string, error) {
  119. if key.runtimeID == "" {
  120. return "", "", errors.New("agent identity runtime id is missing")
  121. }
  122. formatted := timestamp.UTC().Format(time.RFC3339)
  123. signature, err := key.privateKey.Sign(nil, []byte(key.runtimeID+":"+formatted), crypto.Hash(0))
  124. if err != nil {
  125. return "", "", errors.New("failed to sign agent task registration")
  126. }
  127. return formatted, base64.StdEncoding.EncodeToString(signature), nil
  128. }
  129. func decryptAgentTaskID(key agentIdentityKey, encoded string) (string, error) {
  130. ciphertext, err := base64.StdEncoding.DecodeString(strings.TrimSpace(encoded))
  131. if err != nil {
  132. return "", errors.New("encrypted agent task id is not valid base64")
  133. }
  134. seed := key.privateKey.Seed()
  135. digest := sha512.Sum512(seed)
  136. var curvePrivate [32]byte
  137. copy(curvePrivate[:], digest[:32])
  138. curvePrivate[0] &= 248
  139. curvePrivate[31] &= 127
  140. curvePrivate[31] |= 64
  141. curvePublicBytes, err := curve25519.X25519(curvePrivate[:], curve25519.Basepoint)
  142. if err != nil {
  143. return "", errors.New("failed to derive agent identity decryption key")
  144. }
  145. var curvePublic [32]byte
  146. copy(curvePublic[:], curvePublicBytes)
  147. plaintext, ok := box.OpenAnonymous(nil, ciphertext, &curvePublic, &curvePrivate)
  148. if !ok {
  149. return "", errors.New("failed to decrypt encrypted agent task id")
  150. }
  151. taskID := strings.TrimSpace(string(plaintext))
  152. if taskID == "" {
  153. return "", errors.New("decrypted agent task id is empty")
  154. }
  155. return taskID, nil
  156. }
  157. func registerAgentIdentityTask(ctx context.Context, account *Account) (string, error) {
  158. key, err := agentIdentityKeyFromAccount(account)
  159. if err != nil {
  160. return "", err
  161. }
  162. timestamp, signature, err := signAgentTaskRegistration(key, time.Now())
  163. if err != nil {
  164. return "", err
  165. }
  166. proxyURL := ""
  167. if account.ProxyID != nil && account.Proxy != nil {
  168. proxyURL = account.Proxy.URL()
  169. }
  170. client, err := httpclient.GetClient(httpclient.Options{
  171. ProxyURL: proxyURL,
  172. Timeout: agentIdentityTaskRegistrationTimeout,
  173. ResponseHeaderTimeout: 15 * time.Second,
  174. })
  175. if err != nil {
  176. return "", errors.New("invalid proxy configuration for agent task registration")
  177. }
  178. body, err := json.Marshal(map[string]string{
  179. "timestamp": timestamp,
  180. "signature": signature,
  181. })
  182. if err != nil {
  183. return "", errors.New("failed to serialize agent task registration")
  184. }
  185. url := strings.TrimRight(strings.TrimSpace(openAIAgentIdentityAuthAPIBaseURL), "/") + "/v1/agent/" + key.runtimeID + "/task/register"
  186. req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(string(body)))
  187. if err != nil {
  188. return "", errors.New("failed to build agent task registration request")
  189. }
  190. req.Header.Set("Content-Type", "application/json")
  191. req.Header.Set("Accept", "application/json")
  192. resp, err := client.Do(req)
  193. if err != nil {
  194. return "", errors.New("agent task registration request failed")
  195. }
  196. defer func() { _ = resp.Body.Close() }()
  197. if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
  198. return "", fmt.Errorf("agent task registration returned status %d", resp.StatusCode)
  199. }
  200. var result agentIdentityTaskRegistrationResponse
  201. if err := json.NewDecoder(io.LimitReader(resp.Body, 64*1024)).Decode(&result); err != nil {
  202. return "", errors.New("agent task registration response is invalid")
  203. }
  204. if taskID := strings.TrimSpace(result.TaskID); taskID != "" {
  205. return taskID, nil
  206. }
  207. if taskID := strings.TrimSpace(result.TaskIDCamel); taskID != "" {
  208. return taskID, nil
  209. }
  210. encrypted := strings.TrimSpace(result.EncryptedTaskID)
  211. if encrypted == "" {
  212. encrypted = strings.TrimSpace(result.EncryptedTaskIDCamel)
  213. }
  214. if encrypted == "" {
  215. return "", errors.New("agent task registration response omitted task id")
  216. }
  217. return decryptAgentTaskID(key, encrypted)
  218. }
  219. func ensureAgentIdentityTaskForAccount(ctx context.Context, repo AccountRepository, wsInvalidator agentIdentityWSConnectionInvalidator, taskMu *sync.Mutex, account *Account, expectedTaskID string) error {
  220. if account == nil || !account.IsOpenAIAgentIdentity() {
  221. return nil
  222. }
  223. credAccount := account
  224. if account.IsShadow() {
  225. resolved, err := resolveCredentialAccount(ctx, repo, account)
  226. if err != nil {
  227. return err
  228. }
  229. credAccount = resolved
  230. }
  231. if credAccount == nil || !credAccount.IsOpenAIAgentIdentity() {
  232. return errors.New("agent identity credentials are unavailable")
  233. }
  234. currentTaskID := strings.TrimSpace(credAccount.GetCredential("task_id"))
  235. if currentTaskID != "" && (expectedTaskID == "" || currentTaskID != expectedTaskID) {
  236. return nil
  237. }
  238. if intelligentContext(ctx) != nil {
  239. return errIntelligentCredentialRefreshRequired
  240. }
  241. if taskMu == nil {
  242. return errors.New("agent identity task lock is unavailable")
  243. }
  244. sharedTaskMu := taskMu
  245. if credAccount.ID > 0 {
  246. candidate := &sync.Mutex{}
  247. actual, _ := agentIdentityTaskLocks.LoadOrStore(credAccount.ID, candidate)
  248. loadedTaskMu, ok := actual.(*sync.Mutex)
  249. if !ok {
  250. return errors.New("agent identity task lock has invalid type")
  251. }
  252. sharedTaskMu = loadedTaskMu
  253. }
  254. sharedTaskMu.Lock()
  255. defer sharedTaskMu.Unlock()
  256. // Re-read inside the shared lock. Different request paths often receive
  257. // independent repository snapshots; checking only the caller's snapshot
  258. // would allow sequential duplicate registrations after the first writer
  259. // has already persisted a new task.
  260. if repo != nil && credAccount.ID > 0 {
  261. if refreshed, refreshErr := repo.GetByID(ctx, credAccount.ID); refreshErr == nil && refreshed != nil {
  262. if refreshed.IsShadow() {
  263. if resolved, resolveErr := resolveCredentialAccount(ctx, repo, refreshed); resolveErr == nil && resolved != nil {
  264. refreshed = resolved
  265. }
  266. }
  267. if refreshed.IsOpenAIAgentIdentity() {
  268. credAccount = refreshed
  269. if !account.IsShadow() {
  270. account.Credentials = shallowCopyMap(credAccount.Credentials)
  271. }
  272. }
  273. }
  274. }
  275. currentTaskID = strings.TrimSpace(credAccount.GetCredential("task_id"))
  276. if currentTaskID != "" && (expectedTaskID == "" || currentTaskID != expectedTaskID) {
  277. return nil
  278. }
  279. newTaskID, err := registerAgentIdentityTask(ctx, credAccount)
  280. if err != nil {
  281. return err
  282. }
  283. credentials := make(map[string]any, len(credAccount.Credentials)+1)
  284. for key, value := range credAccount.Credentials {
  285. credentials[key] = value
  286. }
  287. credentials["task_id"] = newTaskID
  288. if err := persistAccountCredentials(ctx, repo, credAccount, credentials); err != nil {
  289. return err
  290. }
  291. if !account.IsShadow() && account != credAccount {
  292. account.Credentials = shallowCopyMap(credAccount.Credentials)
  293. }
  294. if wsInvalidator != nil {
  295. wsInvalidator.InvalidateAgentIdentityWSConnections(credAccount.ID)
  296. }
  297. return nil
  298. }
  299. func (s *OpenAIGatewayService) ensureAgentIdentityTask(ctx context.Context, account *Account, expectedTaskID string) error {
  300. if s == nil {
  301. return errors.New("openai gateway service is nil")
  302. }
  303. return ensureAgentIdentityTaskForAccount(ctx, s.accountRepo, s, &s.agentIdentityTaskMu, account, expectedTaskID)
  304. }
  305. func isAgentIdentityTaskInvalidHTTPResponse(statusCode int, body []byte) bool {
  306. if statusCode != http.StatusUnauthorized {
  307. return false
  308. }
  309. lower := strings.ToLower(string(body))
  310. compact := strings.NewReplacer(" ", "", "\t", "", "\r", "", "\n", "").Replace(lower)
  311. for _, marker := range []string{
  312. `"code":"invalid_task_id"`,
  313. `"code":"task_not_found"`,
  314. `"code":"task_expired"`,
  315. `"error":"invalid_task_id"`,
  316. } {
  317. if strings.Contains(compact, marker) {
  318. return true
  319. }
  320. }
  321. for _, marker := range []string{
  322. "invalid task_id",
  323. "invalid task id",
  324. "task_id is invalid",
  325. "task id is invalid",
  326. "task not found",
  327. "task expired",
  328. "unknown task_id",
  329. "unknown task id",
  330. } {
  331. if strings.Contains(lower, marker) {
  332. return true
  333. }
  334. }
  335. return false
  336. }
  337. type agentIdentityTaskRecoveryContextKey struct{}
  338. func markAgentIdentityTaskRecoveryTried(ctx context.Context) context.Context {
  339. return context.WithValue(ctx, agentIdentityTaskRecoveryContextKey{}, true)
  340. }
  341. func agentIdentityTaskRecoveryWasTried(ctx context.Context) bool {
  342. tried, _ := ctx.Value(agentIdentityTaskRecoveryContextKey{}).(bool)
  343. return tried
  344. }
  345. func isAgentIdentityTaskInvalidWSDialError(err *openAIWSDialError) bool {
  346. return err != nil && isAgentIdentityTaskInvalidHTTPResponse(err.StatusCode, err.ResponseBody)
  347. }
  348. func (s *OpenAIGatewayService) buildOpenAIAuthenticationHeaders(ctx context.Context, account *Account, token string) (http.Header, error) {
  349. if account == nil {
  350. return nil, errors.New("account is nil")
  351. }
  352. credAccount := account
  353. if account.IsShadow() {
  354. resolved, err := resolveCredentialAccount(ctx, s.accountRepo, account)
  355. if err != nil {
  356. return nil, err
  357. }
  358. credAccount = resolved
  359. }
  360. headers := make(http.Header)
  361. if credAccount != nil && credAccount.IsOpenAIAgentIdentity() {
  362. agentHeaders, err := buildAgentIdentityAuthenticationHeaders(ctx, s.accountRepo, s, &s.agentIdentityTaskMu, credAccount)
  363. if err != nil {
  364. return nil, err
  365. }
  366. return agentHeaders, nil
  367. }
  368. headers.Set("Authorization", "Bearer "+token)
  369. return headers, nil
  370. }
  371. func buildAgentIdentityAuthenticationHeaders(ctx context.Context, repo AccountRepository, wsInvalidator agentIdentityWSConnectionInvalidator, taskMu *sync.Mutex, account *Account) (http.Header, error) {
  372. if account == nil || !account.IsOpenAIAgentIdentity() {
  373. return nil, errors.New("agent identity account is required")
  374. }
  375. if err := ensureAgentIdentityTaskForAccount(ctx, repo, wsInvalidator, taskMu, account, ""); err != nil {
  376. return nil, err
  377. }
  378. key, err := agentIdentityKeyFromAccount(account)
  379. if err != nil {
  380. return nil, err
  381. }
  382. assertion, err := buildAgentAssertion(key, time.Now())
  383. if err != nil {
  384. return nil, err
  385. }
  386. headers := make(http.Header)
  387. headers.Set("Authorization", assertion)
  388. return headers, nil
  389. }
  390. func (s *OpenAIGatewayService) refreshOpenAIAgentIdentityHeaders(ctx context.Context, account *Account, headers http.Header) (http.Header, error) {
  391. if account == nil {
  392. return cloneHeader(headers), nil
  393. }
  394. credAccount := account
  395. if account.IsShadow() {
  396. resolved, err := resolveCredentialAccount(ctx, s.accountRepo, account)
  397. if err != nil {
  398. return nil, err
  399. }
  400. credAccount = resolved
  401. }
  402. if !credAccount.IsOpenAIAgentIdentity() {
  403. return cloneHeader(headers), nil
  404. }
  405. refreshed := cloneHeader(headers)
  406. if refreshed == nil {
  407. refreshed = make(http.Header)
  408. }
  409. authHeaders, err := buildAgentIdentityAuthenticationHeaders(ctx, s.accountRepo, s, &s.agentIdentityTaskMu, credAccount)
  410. if err != nil {
  411. return nil, err
  412. }
  413. refreshed.Set("Authorization", authHeaders.Get("Authorization"))
  414. return refreshed, nil
  415. }
  416. func (s *OpenAIGatewayService) recoverAgentIdentityTask(ctx context.Context, account *Account, expectedTaskID string) error {
  417. if account != nil && account.IsShadow() {
  418. if resolved, err := resolveCredentialAccount(ctx, s.accountRepo, account); err == nil && resolved != nil && strings.TrimSpace(expectedTaskID) == "" {
  419. expectedTaskID = strings.TrimSpace(resolved.GetCredential("task_id"))
  420. }
  421. }
  422. return s.ensureAgentIdentityTask(ctx, account, expectedTaskID)
  423. }
  424. func (s *OpenAIGatewayService) isAgentIdentityAccount(ctx context.Context, account *Account) bool {
  425. if account == nil {
  426. return false
  427. }
  428. credAccount := account
  429. if account.IsShadow() {
  430. resolved, err := resolveCredentialAccount(ctx, s.accountRepo, account)
  431. if err != nil {
  432. return false
  433. }
  434. credAccount = resolved
  435. }
  436. return credAccount != nil && credAccount.IsOpenAIAgentIdentity()
  437. }
  438. // redactAgentIdentitySensitiveBody removes credential values before an
  439. // upstream error can reach logs, ops events, or returned error text. Agent
  440. // Identity responses should not echo these values, but keeping this boundary
  441. // defensive prevents accidental disclosure if an upstream error does.
  442. func redactAgentIdentitySensitiveBodyForAccount(ctx context.Context, repo AccountRepository, account *Account, body []byte) []byte {
  443. if account == nil || len(body) == 0 {
  444. return body
  445. }
  446. credAccount := account
  447. if account != nil && account.IsShadow() {
  448. if resolved, err := resolveCredentialAccount(ctx, repo, account); err == nil && resolved != nil {
  449. credAccount = resolved
  450. }
  451. }
  452. if credAccount == nil || !credAccount.IsOpenAIAgentIdentity() {
  453. return body
  454. }
  455. redacted := string(body)
  456. for _, key := range []string{
  457. "agent_private_key",
  458. "agent_runtime_id",
  459. "task_id",
  460. "access_token",
  461. "refresh_token",
  462. "id_token",
  463. "api_key",
  464. "session_key",
  465. "cookie",
  466. } {
  467. if value := strings.TrimSpace(credAccount.GetCredential(key)); value != "" {
  468. redacted = strings.ReplaceAll(redacted, value, "[redacted]")
  469. }
  470. }
  471. const assertionPrefix = "AgentAssertion "
  472. for offset := 0; offset < len(redacted); {
  473. relativeStart := strings.Index(redacted[offset:], assertionPrefix)
  474. if relativeStart < 0 {
  475. break
  476. }
  477. start := offset + relativeStart
  478. valueStart := start + len(assertionPrefix)
  479. end := valueStart
  480. for end < len(redacted) && !strings.ContainsRune(" \t\r\n\"',}", rune(redacted[end])) {
  481. end++
  482. }
  483. redacted = redacted[:valueStart] + "[redacted]" + redacted[end:]
  484. offset = valueStart + len("[redacted]")
  485. }
  486. return []byte(redacted)
  487. }
  488. func (s *OpenAIGatewayService) redactAgentIdentitySensitiveBody(ctx context.Context, account *Account, body []byte) []byte {
  489. if !s.isAgentIdentityAccount(ctx, account) {
  490. return body
  491. }
  492. return redactAgentIdentitySensitiveBodyForAccount(ctx, s.accountRepo, account, body)
  493. }