GenerationContext.php 4.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135
  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. * 解密 context_data
  30. *
  31. * @param mixed $value 前端传入的 context_data
  32. * @return string prompt 原文
  33. * @throws \RuntimeException 编码/版本错误、密钥错误或认证/解密失败
  34. */
  35. public static function decode($value)
  36. {
  37. if (!is_string($value) || $value === '') {
  38. throw new \RuntimeException('context_data 编码或版本错误');
  39. }
  40. $packet = base64_decode($value, true);
  41. // 回写比对用于排除空白字符、URL-safe 变体等非标准 Base64 编码
  42. if ($packet === false
  43. || base64_encode($packet) !== $value
  44. || strlen($packet) < self::MIN_PACKET_LENGTH
  45. || ord($packet[0]) !== self::VERSION) {
  46. throw new \RuntimeException('context_data 编码或版本错误');
  47. }
  48. $key = hex2bin(self::KEY_HEX);
  49. if ($key === false || strlen($key) !== 32) {
  50. throw new \RuntimeException('context_data 密钥配置错误');
  51. }
  52. $iv = substr($packet, 1, 12);
  53. $tag = substr($packet, -16);
  54. $ciphertext = substr($packet, 13, -16);
  55. $plain = openssl_decrypt(
  56. $ciphertext,
  57. 'aes-256-gcm',
  58. $key,
  59. OPENSSL_RAW_DATA,
  60. $iv,
  61. $tag,
  62. self::AAD
  63. );
  64. if ($plain === false) {
  65. throw new \RuntimeException('context_data 认证或解密失败');
  66. }
  67. return $plain;
  68. }
  69. /**
  70. * 校验 prompt 与 context_data 是否逐字节一致
  71. *
  72. * 不对 prompt 做 trim、换行转换、Unicode 归一化、模板替换等任何处理。
  73. *
  74. * @param mixed $prompt
  75. * @param mixed $contextData
  76. * @return array
  77. */
  78. public static function verify($prompt, $contextData)
  79. {
  80. $result = [
  81. 'status' => self::STATUS_MISSING,
  82. 'message' => '',
  83. 'prompt_bytes' => is_string($prompt) ? strlen($prompt) : 0,
  84. 'decoded_bytes' => 0,
  85. 'decoded_sha256' => '',
  86. ];
  87. if ($contextData === null || $contextData === '') {
  88. return $result;
  89. }
  90. if (!is_string($contextData)) {
  91. $result['status'] = self::STATUS_INVALID;
  92. $result['message'] = 'context_data 编码或版本错误';
  93. return $result;
  94. }
  95. try {
  96. $decoded = self::decode($contextData);
  97. } catch (\RuntimeException $e) {
  98. $result['status'] = self::STATUS_INVALID;
  99. $result['message'] = $e->getMessage();
  100. return $result;
  101. }
  102. $result['decoded_bytes'] = strlen($decoded);
  103. $result['decoded_sha256'] = substr(hash('sha256', $decoded), 0, 16);
  104. if (!is_string($prompt) || !hash_equals($prompt, $decoded)) {
  105. $result['status'] = self::STATUS_MISMATCH;
  106. $result['message'] = 'context_data 与 prompt 不一致';
  107. return $result;
  108. }
  109. $result['status'] = self::STATUS_OK;
  110. return $result;
  111. }
  112. }