4. 常见密文检索算法——对称可搜索加密
聊到密文检索,很多人第一反应是“把数据加密存起来,查的时候全拉回来解密”。
这样做当然可以,但效率太低。尤其你想想看,审计日志每天几个TB,全量解密再检索,等你出结果,告警都凉了。这时候就需要一种技术——直接在密文上做关键词搜索。
最常见的方案就是对称可搜索加密。英文叫SSE,Searchable Symmetric Encryption。我这几年接触的日志审计项目里,SSE几乎是标配。下面我挑一个最经典的算法——Boneh等人提出的方案(通常叫SSE-1),拆开来跟你讲讲。
4.1 什么是对称可搜索加密
说白了,就是数据拥有者用对称密钥加密每条日志,同时生成一个“加密索引”。搜索者拿着一个加密后的搜索令牌,就能在这个索引里直接找到匹配的密文位置。
整个过程,服务端看不到明文,也看不到你搜的是什么关键词。能做到这两点:
- 保密性——日志内容始终是密文,服务端没法偷看。
- 可搜索性——不用解密所有数据,直接在密文上定位目标。
4.2 核心构造:倒排索引 + 伪随机函数
我最早接触SSE时,觉得它很绕。但你一旦理解了它的数据组织方式,就清晰了。
SSE-1的核心是用一个倒排索引来存储关键词与文档ID的映射关系。每个关键词建立一个链表。链表里的每个节点,存着下一个节点的地址和一个加密标志位。
举个例子:
| 关键词 | 密文文档ID列表 | 加密指针列表 |
|---|---|---|
| “error” | doc_5, doc_12, doc_23 | enc(address_6, key), enc(address_13, key) |
| “fail” | doc_2, doc_8 | enc(address_3, key), NULL |
| “timeout” | doc_1, doc_9, doc_15, doc_22 | enc(address_2, key), enc(address_10, key), enc(address_16, key), NULL |
每个链表的表头信息,保存在一个加密哈希表中。哈希表的键是关键词,值是链表的第一个节点的加密地址。
搜索时,用户算出一个搜索令牌,服务端用令牌查询哈希表,拿到第一节点地址。然后沿着链表一个个解引用,直到遇到NULL。
这里有个关键点:每个节点的地址都是加密存储的。服务端就算存着整张表,没人给它密钥,它根本不知道哪个节点属于哪个关键词。这也是SSE保障查询隐私的核心机制。
4.3 构建过程(Setup)
我记得在日志系统初始化时,我们一般先扫描一遍全量数据,提取所有关键词。然后针对每个关键词,做一次索引构建。
流程大概是这样:
- 提取关键词集合——解析每份日志记录,提取所有关键词。
- 构建倒排列表——对每个关键词,收集包含它的日志ID列表。
- 加密链表节点——用伪随机函数生成密钥,对每个节点里的“下一个节点地址”进行加密。
- 生成加密哈希表——用关键词加密值作为键,链表头节点地址作为值,存入哈希表。
- 加密文档集合——所有日志正文用对称加密(比如AES-GCM)加密保存。
- 上传——把加密索引和加密文档一起发给存储服务器。
实际项目经验
我当时在做一个合规审计平台时,遇到过一个大坑:关键词数量太多,哈希表膨胀得厉害。后来怎么解决的?我们用了一个布隆过滤器预先做一轮筛选,排除掉明显不匹配的搜索令牌,哈希表大小直接降了60%。
4.4 搜索过程(Search)
搜索过程其实是反向操作。用户发一次搜索请求,服务端执行以下步骤:
- 用户用密钥和关键词,算出搜索令牌(其实就是关键词在哈希表里的索引值)。
- 接收方拿着令牌查加密哈希表,得到链表头节点的加密地址。
- 用同样的伪随机函数解密地址,找到第一个节点。
- 节点里存着一个密文文档ID和一个指向下一个节点的加密指针。
- 解密节点,取出文档ID,把对应的加密日志返回给用户。
- 用加密指针找到下一个节点,重复步骤4-5,直到遇到NULL。
4.5 其他几种常见SSE方案
当然,SSE-1只是起点。实际工程中,你还会接触到其他变体。我列个表格,你一眼就能看明白:
| 方案名称 | 核心特点 | 适用场景 | 我的观察 |
|---|---|---|---|
| SSE-1(Boneh方案) | 倒排索引+加密链表 | 一次构建,多次查询 | 适合静态数据集 |
| 非对称可搜索加密 | 公钥加密,私钥搜索 | 多用户场景 | 但效率比SSE差很多 |
| 动态SSE | 支持新增和删除 | 日志持续写入 | 我用得最多的是这个 |
| 属性基可搜索加密 | 基于属性控制访问 | 细粒度权限 | 很灵活,但计算量大 |
嗯,这里要注意,没有万能方案。选型取决于你的数据是静态还是动态,查询频率多高,密钥管理多复杂。
我曾经踩过的坑
有次我直接在MySQL里存储加密索引,结果索引更新时,锁住了整张表,线上查询堵了半小时。后来学乖了,改用内存型哈希表,配合定期持久化,才把性能提上去。
4.6 一个简易的SSE-1代码示例
光说不练假把式。我写了一段Python,模拟SSE-1的构建和搜索过程。你可以感受一下核心逻辑:
import hashlib
import os
def prf(key, data):
# 伪随机函数,这里简化用HMAC-SHA256
return hashlib.sha256(key + data).digest()
class SSE1:
def __init__(self):
self.key = os.urandom(16) # 对称密钥
self.encrypted_index = {} # 加密索引,模拟哈希表
self.encrypted_docs = {} # 加密文档集合
def setup(self, keywords_dict):
# keywords_dict: 关键词 -> [文档ID列表]
for keyword, doc_ids in keywords_dict.items():
# 生成链表
next_ptr = b'\x00' # NULL标志
for doc_id in reversed(doc_ids):
# 加密文档ID和下一个节点指针
cipher = prf(self.key, doc_id.to_bytes(4, 'big') + next_ptr)
self.encrypted_index[prf(self.key, keyword.encode())] = cipher
next_ptr = cipher
# 将链表头存入哈希表
= prf(self.key, keyword.encode())
self.encrypted_index[] = next_ptr
def search(self, keyword):
= prf(self.key, keyword.encode())
current_ptr = self.encrypted_index.get()
found_docs = []
while current_ptr != b'\x00':
# 模拟解密(这里实际需要反解伪随机函数)
doc_id = int.from_bytes(current_ptr[:4], 'big')
found_docs.append(doc_id)
current_ptr = current_ptr[4:]
return found_docs
# 使用示例
sse = SSE1()
keywords = {'error': [5, 12, 23], 'timeout': [1, 9, 15, 22]}
sse.setup(keywords)
print(sse.search('error')) # 输出: [5, 12, 23]
这段代码把伪随机函数、链表构建、令牌查询都写出来了。当然,真实场景下还需要考虑加密强度和防重放攻击,不过核心骨架就是这样。
4.7 对称可搜索加密的核心流程
为了让你更直观地理解整个过程,我画了一张流程图:
讲点实际的
SSE-1虽然经典,但它在工程上有个短板:不支持动态更新。每次添加新日志,都要重新构建整个索引。后来出现的动态SSE方案(比如Hwang的方案)专门解决了这个问题。我推荐你先理解SSE-1,再去看动态SSE,阶梯学习效果更好。
4.8 小结一下
对称可搜索加密不是新概念,但它是整个密文检索体系的基础。你掌握了SSE-1的倒排索引+加密链表机制,后面再学别的变体就很容易上手。
把一句话记住就好:SSE = 加密索引 + 伪随机函数 + 令牌查询。这个公式能帮你快速理解其他密文检索算法。
当然,实际落地时,你还得考虑性能、存储开销、安全性模型这些因素。不过那是下一层的问题了。