|
|
@@ -0,0 +1,135 @@
|
|
|
+<?php
|
|
|
+
|
|
|
+
|
|
|
+namespace App\Libs;
|
|
|
+
|
|
|
+
|
|
|
+/**
|
|
|
+ * 生图/视频提示词上下文(context_data)解密与校验
|
|
|
+ *
|
|
|
+ * 协议第一版:AES-256-GCM
|
|
|
+ * 数据包:0x01 || IV(12字节) || 密文 || Tag(16字节),整体使用标准 Base64 编码
|
|
|
+ * AAD:sound-ai:context_data:v1
|
|
|
+ */
|
|
|
+class GenerationContext
|
|
|
+{
|
|
|
+ /** 协议版本 */
|
|
|
+ const VERSION = 1;
|
|
|
+
|
|
|
+ /** 数据包最小长度:版本 1 字节 + IV 12 字节 + Tag 16 字节 */
|
|
|
+ const MIN_PACKET_LENGTH = 29;
|
|
|
+
|
|
|
+ /** 联调共享密钥(64 位十六进制字符串,解码后为 32 字节) */
|
|
|
+ const KEY_HEX = 'f1b20a84c45fe3dbb270e867f9b1a181e9e5ffb3f06fce80341f8a35808f9b6a';
|
|
|
+
|
|
|
+ /** 附加认证数据 */
|
|
|
+ const AAD = 'sound-ai:context_data:v1';
|
|
|
+
|
|
|
+ /** 校验通过 */
|
|
|
+ const STATUS_OK = 'ok';
|
|
|
+ /** 未携带 context_data */
|
|
|
+ const STATUS_MISSING = 'missing';
|
|
|
+ /** 编码/版本错误或认证/解密失败 */
|
|
|
+ const STATUS_INVALID = 'invalid';
|
|
|
+ /** 解密成功但与 prompt 不一致 */
|
|
|
+ const STATUS_MISMATCH = 'mismatch';
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 解密 context_data
|
|
|
+ *
|
|
|
+ * @param mixed $value 前端传入的 context_data
|
|
|
+ * @return string prompt 原文
|
|
|
+ * @throws \RuntimeException 编码/版本错误、密钥错误或认证/解密失败
|
|
|
+ */
|
|
|
+ public static function decode($value)
|
|
|
+ {
|
|
|
+ if (!is_string($value) || $value === '') {
|
|
|
+ throw new \RuntimeException('context_data 编码或版本错误');
|
|
|
+ }
|
|
|
+
|
|
|
+ $packet = base64_decode($value, true);
|
|
|
+ // 回写比对用于排除空白字符、URL-safe 变体等非标准 Base64 编码
|
|
|
+ if ($packet === false
|
|
|
+ || base64_encode($packet) !== $value
|
|
|
+ || strlen($packet) < self::MIN_PACKET_LENGTH
|
|
|
+ || ord($packet[0]) !== self::VERSION) {
|
|
|
+ throw new \RuntimeException('context_data 编码或版本错误');
|
|
|
+ }
|
|
|
+
|
|
|
+ $key = hex2bin(self::KEY_HEX);
|
|
|
+ if ($key === false || strlen($key) !== 32) {
|
|
|
+ throw new \RuntimeException('context_data 密钥配置错误');
|
|
|
+ }
|
|
|
+
|
|
|
+ $iv = substr($packet, 1, 12);
|
|
|
+ $tag = substr($packet, -16);
|
|
|
+ $ciphertext = substr($packet, 13, -16);
|
|
|
+
|
|
|
+ $plain = openssl_decrypt(
|
|
|
+ $ciphertext,
|
|
|
+ 'aes-256-gcm',
|
|
|
+ $key,
|
|
|
+ OPENSSL_RAW_DATA,
|
|
|
+ $iv,
|
|
|
+ $tag,
|
|
|
+ self::AAD
|
|
|
+ );
|
|
|
+
|
|
|
+ if ($plain === false) {
|
|
|
+ throw new \RuntimeException('context_data 认证或解密失败');
|
|
|
+ }
|
|
|
+
|
|
|
+ return $plain;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 校验 prompt 与 context_data 是否逐字节一致
|
|
|
+ *
|
|
|
+ * 不对 prompt 做 trim、换行转换、Unicode 归一化、模板替换等任何处理。
|
|
|
+ *
|
|
|
+ * @param mixed $prompt
|
|
|
+ * @param mixed $contextData
|
|
|
+ * @return array
|
|
|
+ */
|
|
|
+ public static function verify($prompt, $contextData)
|
|
|
+ {
|
|
|
+ $result = [
|
|
|
+ 'status' => self::STATUS_MISSING,
|
|
|
+ 'message' => '',
|
|
|
+ 'prompt_bytes' => is_string($prompt) ? strlen($prompt) : 0,
|
|
|
+ 'decoded_bytes' => 0,
|
|
|
+ 'decoded_sha256' => '',
|
|
|
+ ];
|
|
|
+
|
|
|
+ if ($contextData === null || $contextData === '') {
|
|
|
+ return $result;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!is_string($contextData)) {
|
|
|
+ $result['status'] = self::STATUS_INVALID;
|
|
|
+ $result['message'] = 'context_data 编码或版本错误';
|
|
|
+ return $result;
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ $decoded = self::decode($contextData);
|
|
|
+ } catch (\RuntimeException $e) {
|
|
|
+ $result['status'] = self::STATUS_INVALID;
|
|
|
+ $result['message'] = $e->getMessage();
|
|
|
+ return $result;
|
|
|
+ }
|
|
|
+
|
|
|
+ $result['decoded_bytes'] = strlen($decoded);
|
|
|
+ $result['decoded_sha256'] = substr(hash('sha256', $decoded), 0, 16);
|
|
|
+
|
|
|
+ if (!is_string($prompt) || !hash_equals($prompt, $decoded)) {
|
|
|
+ $result['status'] = self::STATUS_MISMATCH;
|
|
|
+ $result['message'] = 'context_data 与 prompt 不一致';
|
|
|
+ return $result;
|
|
|
+ }
|
|
|
+
|
|
|
+ $result['status'] = self::STATUS_OK;
|
|
|
+
|
|
|
+ return $result;
|
|
|
+ }
|
|
|
+}
|