GenerationContext.php 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178
  1. <?php
  2. namespace App\Libs;
  3. /**
  4. * 文字/图片/视频提示词上下文(context_data)解密与校验
  5. *
  6. * 协议第一版:AES-256-GCM
  7. * 数据包:0x01 || IV(12字节) || 密文 || Tag(16字节),整体使用标准 Base64 编码
  8. * AAD:sound-ai:context_data:v1
  9. */
  10. class GenerationContext
  11. {
  12. /** 协议版本 */
  13. const VERSION = 1;
  14. /** 数据包最小长度:版本 1 字节 + IV 12 字节 + Tag 16 字节 */
  15. const MIN_PACKET_LENGTH = 29;
  16. /** 联调共享密钥(64 位十六进制字符串,解码后为 32 字节) */
  17. const KEY_HEX = 'f1b20a84c45fe3dbb270e867f9b1a181e9e5ffb3f06fce80341f8a35808f9b6a';
  18. /** 附加认证数据 */
  19. const AAD = 'sound-ai:context_data:v1';
  20. /** 校验通过 */
  21. const STATUS_OK = 'ok';
  22. /** 未携带 context_data */
  23. const STATUS_MISSING = 'missing';
  24. /** 编码/版本错误或认证/解密失败 */
  25. const STATUS_INVALID = 'invalid';
  26. /** 解密成功但与 prompt 不一致 */
  27. const STATUS_MISMATCH = 'mismatch';
  28. /**
  29. * 按 prompt 优先策略处理生成请求;无 prompt 时将解密结果写回。
  30. * 两者均为空时保持原参数,由业务层继续校验。
  31. *
  32. * @param array $data 请求参数(引用传递)
  33. * @param string $scene 接口场景标识
  34. * @param string $logChannel 日志分类
  35. * @throws \App\Exceptions\ApiException 解密失败
  36. */
  37. public static function apply(&$data, $scene, $logChannel = 'anime')
  38. {
  39. $prompt = is_array($data) ? getProp($data, 'prompt', null) : null;
  40. $contextData = is_array($data) ? getProp($data, 'context_data', null) : null;
  41. if (is_string($prompt) && trim($prompt) !== '') {
  42. return;
  43. }
  44. if ($contextData !== null && $contextData !== '') {
  45. try {
  46. $decoded = self::decode($contextData);
  47. } catch (\RuntimeException $e) {
  48. dLog($logChannel)->error('生成接口 context_data 解密失败', [
  49. 'scene' => $scene,
  50. 'detail' => $e->getMessage(),
  51. ]);
  52. Utils::throwError('20003:' . $e->getMessage());
  53. }
  54. $data['prompt'] = $decoded;
  55. dLog($logChannel)->info('生成接口使用 context_data 解密结果作为提示词', [
  56. 'scene' => $scene,
  57. 'decoded_bytes' => strlen($decoded),
  58. ]);
  59. return;
  60. }
  61. dLog($logChannel)->warning('生成接口未提供 prompt 与 context_data', [
  62. 'scene' => $scene,
  63. ]);
  64. }
  65. /**
  66. * 解密 context_data
  67. *
  68. * @param mixed $value 前端传入的 context_data
  69. * @return string prompt 原文
  70. * @throws \RuntimeException 编码/版本错误、密钥错误或认证/解密失败
  71. */
  72. public static function decode($value)
  73. {
  74. if (!is_string($value) || $value === '') {
  75. throw new \RuntimeException('context_data 编码或版本错误');
  76. }
  77. $packet = base64_decode($value, true);
  78. // 回写比对用于排除空白字符、URL-safe 变体等非标准 Base64 编码
  79. if ($packet === false
  80. || base64_encode($packet) !== $value
  81. || strlen($packet) < self::MIN_PACKET_LENGTH
  82. || ord($packet[0]) !== self::VERSION) {
  83. throw new \RuntimeException('context_data 编码或版本错误');
  84. }
  85. $key = hex2bin(self::KEY_HEX);
  86. if ($key === false || strlen($key) !== 32) {
  87. throw new \RuntimeException('context_data 密钥配置错误');
  88. }
  89. $iv = substr($packet, 1, 12);
  90. $tag = substr($packet, -16);
  91. $ciphertext = substr($packet, 13, -16);
  92. $plain = openssl_decrypt(
  93. $ciphertext,
  94. 'aes-256-gcm',
  95. $key,
  96. OPENSSL_RAW_DATA,
  97. $iv,
  98. $tag,
  99. self::AAD
  100. );
  101. if ($plain === false) {
  102. throw new \RuntimeException('context_data 认证或解密失败');
  103. }
  104. return $plain;
  105. }
  106. /**
  107. * 校验 prompt 与 context_data 是否逐字节一致
  108. *
  109. * 不对 prompt 做 trim、换行转换、Unicode 归一化、模板替换等任何处理。
  110. *
  111. * @param mixed $prompt
  112. * @param mixed $contextData
  113. * @return array
  114. */
  115. public static function verify($prompt, $contextData)
  116. {
  117. $result = [
  118. 'status' => self::STATUS_MISSING,
  119. 'message' => '',
  120. 'prompt_bytes' => is_string($prompt) ? strlen($prompt) : 0,
  121. 'decoded_bytes' => 0,
  122. 'decoded_sha256' => '',
  123. ];
  124. if ($contextData === null || $contextData === '') {
  125. return $result;
  126. }
  127. if (!is_string($contextData)) {
  128. $result['status'] = self::STATUS_INVALID;
  129. $result['message'] = 'context_data 编码或版本错误';
  130. return $result;
  131. }
  132. try {
  133. $decoded = self::decode($contextData);
  134. } catch (\RuntimeException $e) {
  135. $result['status'] = self::STATUS_INVALID;
  136. $result['message'] = $e->getMessage();
  137. return $result;
  138. }
  139. $result['decoded_bytes'] = strlen($decoded);
  140. $result['decoded_sha256'] = substr(hash('sha256', $decoded), 0, 16);
  141. if (!is_string($prompt) || !hash_equals($prompt, $decoded)) {
  142. $result['status'] = self::STATUS_MISMATCH;
  143. $result['message'] = 'context_data 与 prompt 不一致';
  144. return $result;
  145. }
  146. $result['status'] = self::STATUS_OK;
  147. return $result;
  148. }
  149. }