前阵子我们给内部一套大模型问答系统做安全改造,团队争得最多的不是模型效果,而是提示词走公网时怎么保证不被人截走、改掉。问题听起来挺基础,但真正要把提示工程加密传输机制落地,牵扯到证书体系、应用层加密、防重放、日志脱敏,每一环都有不少细节。这篇文章就围绕这套机制,从原理到工具再到实战案例,把能直接抄作业的部分都摊开讲。
如果你正在做AI应用开发、模型API对接,或者需要跨网络传递用户上下文,这套机制的核心逻辑是通用的。和普通接口加密最大的区别在于:提示词不只是一段数据,它会被模型直接当成指令执行。所以除了机密性,完整性和请求来源可信性同样重要。接下来不会讲太多理论,主要是我在实际改造中验证过的方案,以及那些不踩一次很难发现的坑。
1. 先理解风险:提示词在传输环节到底怕什么
1.1 提示词比普通接口数据更敏感,因为它是“可执行的上下文”
很多人觉得提示词就是一段自然语言,加密不加密无所谓。但真实业务里的提示词,往往由系统指令、用户问题、检索到的知识片段、工具调用参数、历史对话拼接而成。里面可能藏着内部系统名称、数据库字段、外部接口的key、风控规则,甚至用户的手机号和地址。一旦明文泄露,不只是用户隐私问题,还等于把系统的“模型用法说明书”交给了攻击者。
更关键的是,提示词具备可执行性。攻击者如果在传输过程中篡改一个词,比如把“只允许查询本部门数据”改成“允许查询全公司数据”,模型输出的权限边界就完全变了。这个影响比篡改普通JSON字段要直接得多,因为提示词会驱动模型做决策、调工具、访问外部系统。所以防篡改不是可选项,是必须项。
1.2 四条攻击路径决定了加密方案的设计边界
设计加密方案之前,先要搞清楚到底在防谁。我通常把风险归成四类,每一类对应一种防护机制。
- 窃听:明文提示词在链路中被第三方读取,比如WiFi嗅探、路由劫持、机房链路监听。防护手段是传输层加密和应用层加密叠加。
- 篡改:中间人修改提示词中的关键指令、阈值、工具参数。防护手段是AEAD密文认证和签名校验。
- 重放:攻击者截获一个合法请求,原封不动重新发送,造成重复计费、重复任务、重复审批。防护手段是请求时间戳加Nonce缓存。
- 身份冒用:攻击者伪装成合法客户端调用模型服务,获取模型能力和敏感应答。防护手段是mTLS双向证书校验和API密钥绑定。
这四条路径直接决定方案结构,不能只靠一个HTTPS证书解决。对提示词这类高价值流量,我建议按“传输层mTLS + 应用层AES-GCM封装 + 请求唯一性校验”三层来做。下面逐个拆开讲。
2. 加密体系拆解:从TLS到应用层,每一层都在防什么
2.1 传输层加密:TLS不是终点,mTLS才是公网调用标准
普通HTTPS只是客户端验证服务端证书,确保连接不被打洞,但服务端并不知道客户端是谁。在这个基础上,任何拿到公网入口地址的人都可以发起加密连接,然后触发模型调用,消耗你的算力,甚至通过试错构造恶意提示词。所以公网场景下,我坚持使用mTLS双向认证。
mTLS做的事情是:客户端携带自己的证书,服务端除了校验TLS证书,还要求客户端证书由受信任的CA签发。网关在握手阶段直接拒绝没有证书或证书非法的连接。对于内部系统之间,我会用自建CA签发所有节点证书,而不是直接买公网HTTPS证书。这样信任根完全掌握在自己手里,证书吊销和轮换也更好控制。
证书链和信任根是mTLS最容易出错的地方。客户端需要配置完整CA链,服务端必须把客户端证书对应的根CA放进信任列表。验证时不能只验证叶子证书,要把整条链追到自建CA。下面是一段生成自签CA和服务端证书的常用命令,实测足够支撑测试环境。
# 生成自签CA,私钥要严格保管 openssl req -x509 -newkey rsa:4096 -keyout ca.key -out ca.crt -days 3650 -nodes -subj "/CN=Internal CA" # 为网关生成服务端证书并用CA签发 openssl req -newkey rsa:2048 -keyout server.key -out server.csr -nodes -subj "/CN=api.internal.local" openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.crt -CAcreateserial -out server.crt -days 730 # 为客户端生成证书,client-a代表一方调用方 openssl req -newkey rsa:2048 -keyout client-a.key -out client-a.csr -nodes -subj "/CN=client-a" openssl x509 -req -in client-a.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out client-a.crt -days 730这里有个实测心得:CA私钥的权限一定要收得非常紧,只允许少数运维人员通过堡垒机操作。如果CA私钥泄露,整个信任体系都要重建,成本极高。
2.2 应用层加密:让网关和日志都看不到明文
传输层加密能保证数据在链路上不被看到,但到了网关这一层,TLS会话会被终止。如果网关要转发请求到模型服务,或者审计系统要记录请求内容,那么提示词在网关进程内就是明文状态。有些框架会在调试日志里自动打印请求body,一旦开了debug,明文提示词就进了日志系统。这时候就需要应用层加密。
应用层加密的思路俗称“信封加密”。客户端每次请求随机生成一个临时对称密钥,用这个密钥加密提示词,再用服务端的公钥把临时密钥包起来。服务端收到包后,先用私钥解出临时密钥,再用临时密钥解出提示词。整个过程只有持有私钥的服务端能解开,网关如果只做透传,看到的始终是密文。
对称加密我推荐AES-256-GCM。GCM是AEAD模式,加密时同时生成认证标签,解密时如果密文被改过,认证直接失败。这天然满足提示词防篡改的需求。临时密钥虽然用RSA包了一层,但RSA只负责解出对称密钥,真正的数据加解密开销很小,性能上可以接受。下面是封装好的加解密模块,基于Python的cryptography库实现。
import os import json import time import base64 from cryptography.hazmat.primitives.ciphers.aead import AESGCM from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import padding class PromptCrypto: def __init__(self, public_key_path, private_key_path=None, key_id="kek-2025-01"): with open(public_key_path, "rb") as f: self.public_key = serialization.load_pem_public_key(f.read()) self.key_id = key_id self.private_key = None if private_key_path: with open(private_key_path, "rb") as f: self.private_key = serialization.load_pem_private_key(f.read(), password=None) def encrypt(self, plaintext: str) -> bytes: # 每次请求随机生成临时对称密钥,避免密钥复用 aes_key = os.urandom(32) nonce = os.urandom(12) ciphertext = AESGCM(aes_key).encrypt(nonce, plaintext.encode("utf-8"), None) # 用服务端公钥包裹临时密钥 encrypted_key = self.public_key.encrypt( aes_key, padding.OAEP( mgf=padding.MGF1(algorithm=hashes.SHA256()), algorithm=hashes.SHA256(), label=None, ), ) body = { "version": 1, "key_id": self.key_id, "algorithm": "AES-256-GCM", "encrypted_key": base64.b64encode(encrypted_key).decode(), "nonce": base64.b64encode(nonce).decode(), "ciphertext": base64.b64encode(ciphertext).decode(), "timestamp": int(time.time()), } return json.dumps(body).encode("utf-8") def decrypt(self, packet: bytes) -> str: body = json.loads(packet) encrypted_key = base64.b64decode(body["encrypted_key"]) nonce = base64.b64decode(body["nonce"]) ciphertext = base64.b64decode(body["ciphertext"]) aes_key = self.private_key.decrypt( encrypted_key, padding.OAEP( mgf=padding.MGF1(algorithm=hashes.SHA256()), algorithm=hashes.SHA256(), label=None, ), ) plaintext = AESGCM(aes_key).decrypt(nonce, ciphertext, None) return plaintext.decode("utf-8")这个模块在测试环境跑起来很顺,但用到生产前有几个细节必须改。key_id不能是写死的字符串,要从KMS或配置中心动态获取。nonce要确保随机且无规律,如果出现重复nonce,同一个AES密钥下会直接破坏加密安全。服务端解密时还要校验请求时间戳,超过允许的窗口期就拒绝,否则重放攻击仍然有效。
2.3 端到端加密在推理场景的边界:密文推理还不划算
有人会说,既然要安全,干脆做到真正的端到端加密,让模型服务方也看不到明文。想法很好,但实际情况是:模型推理必须拿到明文提示词才能生成结果。我们不可能把密文直接喂给Transformer,至少目前的通用模型做不到。
同态加密和多方安全计算可以让模型在密文上做计算,但性能开销极大,离生产还有距离。我的建议是务实一点,做“可信边界内的端到端”。也就是客户端到安全接入层之间做应用层加密,接入层到模型服务之间用mTLS加固,整个链路任何一环都不允许明文落盘。
如果某些字段特别敏感,可以再做字段级加密。比如用户手机号、身份证号在拼进提示词之前先脱敏或加密,模型看到的是脱敏后的占位符,输出结果再映射回真实值。这种做法的好处是,即使提示词被完整截获,核心敏感字段也无法直接使用。
3. 实操选型:加密库、密钥管理、安全网关怎么配
3.1 加密库与密钥管理:比选算法更难的是选密钥托管方式
算法本身没什么好纠结的,AES-256-GCM和RSA-OAEP是成熟组合。真正决定方案成败的是密钥放哪、怎么轮换。把私钥硬编码在代码库里,加密等于白做,攻击者拿到源码就等于拿到所有密钥。
我建议主密钥托管到KMS或HSM,程序里只保存一个key_id。需要加密数据时,调用KMS生成数据密钥,KMS返回明文数据密钥和密文版本。解密时用key_id告诉KMS需要哪把密钥。KMS通常都支持密钥版本概念,轮换时可以保留旧版本继续解密历史数据,新数据用新版本加密,平滑过渡。
信封加密的密钥层级是这样:最高层级是主密钥,通常存在KMS或HSM里,永远不离开加密机;中间层级是数据密钥,由KMS生成,用于实际加解密;最底层是临时会话密钥,就是代码里每次随机生成的AES-256密钥。KMS生成的密钥如果不需要持久化,可以每次调用;如果需要性能优化,可以缓存一段时间,但一定要控制在合理时间范围内。
密钥轮换策略我踩过坑。以前是每季度手动换一次证书,结果某次轮换后忘记更新客户端的CA信任列表,服务直接断连三小时。后来改成自动化轮换:证书两天前自动检查有效期,到期前自动签发,客户端通过配置中心动态拉取信任链,不需要人工干预。轮换期间保留新老密钥双读窗口,直到确认老密钥不再有流量。
3.2 安全网关与接入层:统一收口,但绝不记录明文
安全网关是整套机制里适合统一收口的地方。客户端证书校验、限流、黑白名单、审计,全都可以在这里集中做。网关负责终止TLS,也负责验证客户端发来的应用层密文包格式是否合法,甚至可以在这里做提示词注入特征的粗粒度检测,但我不建议让网关解密业务数据,除非你对网关的安全性有绝对把握。
网关到模型服务之间如果走内网,很多团队会直接用明文HTTP。我对此的建议是:能加密就加密。内网不代表绝对安全,尤其是多租户机房或云上VPC内部,配置错误、横向移动都是现实风险。gRPC调用直接启用TLS,代价很小,收益很明确。
下面是安全网关的TLS配置片段。这里只展示和加密传输直接相关的部分,限流、路由等其他配置按正常方式加。
server { listen 443 ssl http2; server_name api.internal.local; # 服务端证书与私钥 ssl_certificate /etc/ssl/certs/server.crt; ssl_certificate_key /etc/ssl/private/server.key; # 客户端证书链校验,必须开启,不能是optional ssl_client_certificate /etc/ssl/certs/internal-ca.crt; ssl_verify_client on; ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers HIGH:!aNULL:!MD5; # 密文包会比原始提示词大,需要调整body限制 client_max_body_size 4m; }注意一个细节:ssl_verify_client如果配成on,客户端没有证书时握手直接失败。如果配成optional,网关不会拒绝无证书请求,而是把证书校验结果放到请求头里,由业务逻辑自行判断。很多事故就是这里用了optional,下游又没做二次校验,导致“看着开了mTLS,实际等于没开”。
3.3 一个可复用的轻量级加解密模块:别用网上跑不通的伪代码
上一节已经给出了PromptCrypto的核心代码,这里再说下接入时的注意点。加密模块要同时支持客户端和服务端两类使用场景。客户端只需要加载服务端公钥,服务端才需要加载私钥。所以类的设计上,public_key_path是必选参数,private_key_path是可选参数,服务端实例化时传入私钥路径,客户端不传。
在正式项目里,我不建议直接读本地文件。公钥可以存KMS的公开区域,或通过配置中心下发。私钥只能在服务端受保护目录存在,且需要严格权限控制。文件权限至少是600,属主是运行服务的账户。
解密接口还需要额外加两个逻辑。一个是时间戳校验,请求包里的timestamp离当前时间超过60秒直接拒绝,防止老包重放。另一个是request_id去重,用Redis或本地缓存记录最近五分钟内处理过的request_id,重复请求直接丢弃。这两个逻辑一起,才能把重放攻击挡住。下面的伪代码展示了服务端中间件里最关键的两步判断。
def check_replay(body): if abs(time.time() - body["timestamp"]) > 60: raise ReplayError("timestamp expired") if request_id_seen(body["request_id"]): raise ReplayError("duplicate request_id") remember(body["request_id"], expire=300)3.4 调试与抓包验证:确认链路上真的没有明文
方案部署完不能只靠“我觉得没问题”,要实际验证。我最常用的验证命令是openssl s_client,它能测试TLS握手是否成功、客户端证书是否被接受、对方的证书链是否完整。
openssl s_client -connect api.internal.local:443 \ -cert client-a.crt -key client-a.key \ -CAfile ca.crt -verify_return_error如果握手成功,说明mTLS这部分通了。接着用curl实际发一个加密请求,同时在网关所在机器或客户端机器上抓包。只要在链路上抓到的HTTP层是空的、TLS应用层数据是密文,就说明传输层加密生效。应用层加密是否生效,则在网关侧打解密前后的日志对比,确认网关在解密前看到的body是一串无法读懂的base64字符。
抓包工具有时候会尝试解密TLS,前提是要提供私钥。我建议不要在正式环境做这种事,私钥一旦交给第三方工具,等于把加密体系的根基交出去。真要验证应用层密文,直接在网关进程内打印收到的原始body即可,不要用抓包工具去解TLS。
4. 实战案例:模拟项目X的提示词加密改造全记录
4.1 需求梳理与架构设计:从“能不能跑”到“安全地跑”
以模拟项目X为例,它是一套内部知识问答系统,客户端部署在几个分支机房,模型服务集中在总部机房,中间跨公网。改造前提示词直接走HTTPS,没有mTLS,也没有应用层加密。接到安全审计通知时,问题清单列了一大串:日志里能看到完整提示词、客户端证书没有做校验、请求可以无限重放。
需求梳理成四条硬指标:第一,传输链路上不允许出现明文提示词;第二,只有模型服务侧的解密模块可以拿到明文;第三,客户端身份必须可信且可审计;第四,重放请求必须被拒绝。架构上选择加一层安全网关,客户端不直接连模型服务。整体链路是“客户端SDK -> 安全网关 -> 模型服务”,三部分之间全部走TLS,并且客户端到安全网关之间额外做应用层信封加密。
为什么不在客户端直接连模型服务?因为模型服务团队不希望改造核心推理接口,而且统一网关更便于做限流和审计。网关只做证书校验、基础检测和转发,不承担业务解码逻辑。解密动作放在模型服务入口的统一中间件里,这样模型服务的内部代码不需要关注加密细节。
4.2 关键配置与改造步骤:一步一动,每步都可回滚
第一步是生成证书体系。内部自建CA,签发三个角色的证书:网关服务端证书、客户端A证书、客户端B证书。生产环境不能使用示例里的同一个CA私钥继续签,应该单独生成新CA,并把CA私钥托管到专用权限环境。
第二步是网关侧启用mTLS。这里踩了一个很典型的坑:网关版本升级后,新配置里多了ssl_verify_client optional的默认值,老配置迁移时没有仔细检查,导致无证书客户端也能握手成功。排查了很久才发现,所以现在每次改完配置都会用无证书请求测试一遍,确认握手失败。
第三步是在客户端封装加密SDK。SDK暴露的接口和原来的HTTP客户端保持一致,业务方改动很小。SDK内部完成JSON序列化、随机生成AES密钥、RSA包裹密钥、AES-GCM加密、时间戳和request_id填充。这个阶段主要验证加密后的包体结构是否符合预期。
第四步是在模型服务入口增加解密中间件。中间件按顺序做四件事:解析密文包、校验时间戳、去重request_id、私钥解出AES密钥并解密。解密出来的明文prompt再交给原有业务逻辑处理。
最终请求包结构参考如下:
{ "version": 1, "key_id": "kek-2025-01", "algorithm": "AES-256-GCM", "encrypted_key": "base64...", "nonce": "base64...", "ciphertext": "base64...", "timestamp": 1735689600, "request_id": "uuid-0001", "client_id": "client-a" }字段的含义很直接。encrypted_key是RSA加密后的AES密钥,ciphertext是加密后的提示词,nonce是AES-GCM需要的随机数,timestamp和request_id用来防重放,client_id让审计能追溯到来源。这个结构不需要额外字段,简洁清楚,团队评审一次就通过了。
第五步是日志脱敏与审计。从改造之日起,所有日志模块禁止打印请求body。原日志系统里的body打印配置全部关闭。新增审计日志只记录request_id、client_id、key_id、密文摘要和处理时间。摘要用SHA256对密文整体计算,既能做完整性核对,又不泄露内容。
4.3 上线测试与踩坑实录:比预想多花了一倍时间
上线前我们做了一套专项测试清单,逐项过。表格里是我觉得最值得保留的几条。
| 测试项 | 预期结果 | 实际结果 |
|---|---|---|
| 无客户端证书请求 | TLS握手失败 | 通过 |
| 篡改密文包中的ciphertext | 解密抛认证失败 | 通过 |
| 过期证书请求 | 握手失败 | 通过 |
| 重放同一个request_id | 第二个请求被拒绝 | 通过 |
| 查看网关和应用日志 | 无明文prompt出现 | 通过 |
| 压测100并发 | 平均耗时增加不超过20% | 通过 |
测试过程中遇到三个比较烦的问题。第一个是AES-GCM的nonce重用。某个客户端在多线程环境下,随机数生成使用了重复种子,导致两个请求加密结果异常。排查后改用操作系统密码学安全随机源,并在线程局部初始化。
第二个是网关的body大小限制。提示词原本只有200KB,加密后因为非对称密钥包裹和base64编码,涨到接近400KB。网关默认client_max_body_size 1m其实没有触发,但后面接的日志系统对body长度有限制,报错过一次。调整所有中间层的上限后解决,同时把监控告警阈值也调大,否则有一天提示词变长时又要半夜爬起来看问题。
第三个是证书过期问题。测试环境证书有效期设置成了90天,结果在第三个月最后一周突然开始大量握手失败。因为没有做证书自动轮换和过期告警,整个团队都没意识到。现在流程是:证书剩余30天时发出提醒,剩余7天时自动续签并热加载,同时监控TLS握手失败率。这套机制上线后,再没出现过证书引起的断连。
4.4 一个经常被忽略的环节:客户端证书的私钥保护
服务端私钥大家都很重视,但客户端私钥经常被忽略。模拟项目X中,客户端A部署在分支机房的一台服务器上,私钥文件权限默认是644,同机房的运维账号可以直接读取。如果这台机器被入侵,攻击者就能拿着私钥伪装成合法客户端调用模型服务。
解决方案是收紧文件权限,并且配置进程以专用账户运行。同时客户端证书有效期设置成180天,用自动化任务定期更新,避免长期有效证书造成更大的泄露面。整个过程不复杂,但确实容易被人忽略,尤其当大家把注意力都放在服务端加密时。
5. 常见问题与避坑手册:我踩过的那些坑
5.1 证书与信任关系的典型问题
证书问题在加密传输里占比很高,很多故障现象都是“连接被重置”或“证书校验失败”,但根因各有不同。我把最常见的几类整理成表格,遇到过一条就能帮你省下半天排障时间。
| 问题 | 典型原因 | 解决办法 |
|---|---|---|
| 客户端报“无法验证服务端证书” | 服务端只发了叶子证书,没有附带中间证书链 | 配置ssl_certificate时使用完整chain文件 |
| 握手成功但业务请求401 | 客户端证书过期,但网关没有启用OCSP吊销校验 | 加入证书过期检查,设置有效期告警 |
| 自建CA私钥泄露 | 权限设置过宽,运维账号可读 | 收回权限,必要时重建CA并重新签发所有证书 |
| 证书固定导致轮换断连 | 客户端写死了服务端证书指纹 | 改为固定CA公钥,或预留备用信任锚点 |
| 时间不同步导致判断失败 | 服务器系统时间偏差超过证书有效期窗口 | 配置NTP时间同步,告警阈值设为10秒 |
证书固定是个容易走极端的话题。固定指纹确实可以降低CA被攻击的风险,但轮换时会非常痛苦。我更推荐固定公钥而不是完整指纹,同时把新公钥的信任提前写入客户端,保持至少两把公钥可用。这样轮换时可以做到不中断服务。
5.2 加密模块性能与兼容性说明
很多团队担心加密会让请求变慢或者模型调用超时。实测下来,AES-GCM加密1MB以内的数据只需要几毫秒,真正消耗在TLS握手和RSA非对称解包裹上。只要合理使用连接复用,性能影响不会超过20%。
优化空间有几个。一是尽量让客户端和网关之间长连接复用,减少TLS握手次数。二是不要把每次请求都做RSA,可以直接用临时会话密钥交换一次后,在会话内多次使用同一个AES密钥,但要控制会话时长。三是如果并发量特别大,可以把RSA换成ECDH,支持前向保密,性能也更好。
兼容性方面,建议在请求包里增加version字段,并让服务端同时支持新旧两种算法一段时间。灰度期间先把新客户端放量到10%,观察日志和错误率,再逐步提升比例。不要一次性全量切换,尤其是老客户端可能没有可升级的SDK。
5.3 日志里的“幽灵明文”:解密后又被框架打印出来了
有一次我们在测试环境验证密文包,网关确实收到了密文,业务日志的第一行也是解密后的明文提示词,但再往下翻,发现应用框架的debug日志把整个请求对象又打印了一遍,包括解密后的prompt。这就是典型的“日志幽灵明文”。
问题根源在于解密中间件把明文放回了请求对象,框架的全局日志拦截器在请求结束时自动打印对象摘要。解决办法是给日志模块增加敏感字段过滤器,明确禁止打印请求body,并且把过滤规则覆盖到trace、debug、exception所有级别。
审计日志也不要记录明文。如果非要保留payload用于排查,只记录密文摘要和请求ID。就算日志系统被拖库,攻击者拿到的也是一堆无意义的哈希。我还见过把Ciphertext本身打印出来的,这其实也有风险,因为同一段明文在不同时间加密会得到不同密文,密文泄露不如明文直接,但仍不建议记录完整密文,摘要就足够了。
5.4 怀疑密钥泄露后的应急处理流程
密钥泄露这种事,希望大家都别遇到,但预案一定要有。一旦日志里出现异常调用、密钥文件权限被改变,或审计发现某个client_id的请求量骤增,按下面步骤处理:
第一,立即吊销可疑客户端证书,更新证书吊销列表。第二,在KMS中轮换对应key_id的主密钥版本,停止用旧版本加密新数据。第三,刷新网关的信任列表,把可疑客户端从白名单移除。第四,导出最近一小时的审计日志,按client_id、request_id、来源IP维度分析异常调用。第五,通知所有对接方使用新的证书和密钥配置。
处理过程中最忌讳的是盲目重启服务。服务重启不会解决密钥泄露问题,反而可能把关键审计日志冲掉。先把现场的日志和请求摘录保留下来,再去做吊销和轮换操作。
这套方案落地之后,我再回看整个改造过程,最大的体会是:加密传输不是给普通HTTP加个S就完事,真正值得认真设计的是密钥生命周期和信任根管理。你甚至可以把加密逻辑本身写得非常漂亮,但密钥一旦硬编码在代码库里,整个方案就等于没有保护。只有把证书轮换自动化、日志脱敏、重放防御这几块基础工作做扎实,加密传输才能长期稳定运行。如果后续要继续扩展,可以在字段级加密和异常访问检测上做文章。但第一步永远是别让明文提示词出现在任何你觉得安全的日志里。