可搜索加密:加密数据的模糊检索方案
作者:lin | 系列:密码学实战
一、问题背景:加密与检索的两难
数据库安全的成熟实践是:敏感字段落盘前加密。比如用户的手机号、身份证号、姓名,写入数据库时用 AES 加密成一串乱码。
但业务马上就会遇到一个问题:
-- 加密前,模糊查询很自然
SELECT * FROM users WHERE name LIKE '%张%';
-- 加密后,name 字段变成了 'a3f8b2c1d4e5...',LIKE 完全失效
SELECT * FROM users WHERE name LIKE '%a3f8%'; -- 这搜的是密文,不是张三直接解密再搜索? 如果表有 1000 万行,每次查询都要全部解密再逐一比对,响应时间从毫秒级变成分钟级,完全不可接受。
保留明文索引? 那加密等于白做–索引泄露的信息量和明文几乎一样。
这就是**可搜索加密(Searchable Encryption, SE)**要解决的核心矛盾:如何在不暴露明文的前提下,高效地对加密数据做检索?
二、四种主流方案对比
方案一:桶索引(Bucket Index)
原理: 直接的思路–为每个关键词建立一个"桶",查询时直接定位到桶。
原始数据:
用户A: 张三, 13800001111
用户B: 张伟, 13900002222
用户C: 李四, 13700003333
桶索引 (关键词 → 记录ID集合):
"张" → {用户A, 用户B}
"李" → {用户C}
"138" → {用户A}
"139" → {用户B}
"137" → {用户C}查询时,用关键词算出桶ID,拿加密的关键词和桶ID做比对,命中则返回对应记录。
/**
* 桶索引方案 - 核心思路
* 关键词通过 HMAC 映射到桶ID,查询时重新计算桶ID即可定位
*/
public class BucketIndex {
private final SecretKey hmacKey; // HMAC 密钥
public BucketIndex(SecretKey hmacKey) {
this.hmacKey = hmacKey;
}
/**
* 为一条记录构建索引
* 将关键词分词后,每个词生成一个桶标记
*/
public Map<String, Set<Long>> buildIndex(String plaintext, long recordId) {
Map<String, Set<Long>> index = new HashMap<>();
// 实际场景中会用分词器,这里简化为逐字分词
for (int i = 0; i < plaintext.length(); i++) {
String keyword = String.valueOf(plaintext.charAt(i));
String bucketTag = hmac(keyword); // 桶标记 = HMAC(关键词, 密钥)
index.computeIfAbsent(bucketTag, k -> new HashSet<>()).add(recordId);
}
return index;
}
/**
* 查询:计算关键词的桶标记,匹配索引
*/
public Set<Long> search(String keyword) {
String bucketTag = hmac(keyword);
// 用 bucketTag 去索引表查找,返回记录ID集合
return indexStore.getOrDefault(bucketTag, Collections.emptySet());
}
private String hmac(String data) {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(hmacKey);
return Hex.encodeHexString(mac.doFinal(data.getBytes(StandardCharsets.UTF_8)));
}
}| 维度 | 评价 |
|---|---|
| ✅ 优点 | 实现简单,查询 O(1),精确匹配完美支持 |
| ❌ 缺点 | 不支持模糊检索(“张三"和"张"是不同的桶),存储开销随关键词数量线性增长 |
| 📌 适用 | 字段值是枚举类型(如城市、部门),或只需要精确匹配 |
方案二:布隆过滤器 + 加密索引
原理: 布隆过滤器(Bloom Filter)本身只判断一个元素"可能存在"或"一定不存在”,并不天然理解"模糊"。要做模糊检索,需要先把明文切成 n-gram/q-gram 等 token,再把加密后的 token 塞进布隆过滤器;查询时把搜索词按同样规则处理后判断。
记录A: "张三" → 分词 {"张", "三", "张三"}
↓
加密后写入布隆过滤器:
BF_A = BloomFilter{ encrypt("张"), encrypt("三"), encrypt("张三") }
查询 "张" → encrypt("张") → BF_A.check() → 可能存在 ✅
查询 "王" → encrypt("王") → BF_A.check() → 一定不存在 ❌这种方案可以支持前缀匹配、子串匹配等模糊检索场景,核心是将模糊问题转化为多个精确关键词的布隆过滤器判断。
/**
* 布隆过滤器 + AES 加密索引方案
*/
public class BloomFilterIndex {
private final AESUtil aes;
private final int expectedInsertions;
private final double fpp; // 误判率 (false positive probability)
public BloomFilterIndex(AESUtil aes, int expectedInsertions, double fpp) {
this.aes = aes;
this.expectedInsertions = expectedInsertions;
this.fpp = fpp;
}
/**
* 为一条记录构建布隆过滤器索引
* 对明文进行 n-gram 分词,加密后插入布隆过滤器
*/
public BloomFilter<String> buildIndex(String plaintext) {
BloomFilter<String> bf = BloomFilter.create(
Funnels.stringFunnel(StandardCharsets.UTF_8),
expectedInsertions, fpp);
// n-gram 分词:支持子串匹配
for (int n = 1; n <= Math.min(plaintext.length(), 4); n++) {
for (int i = 0; i <= plaintext.length() - n; i++) {
String gram = plaintext.substring(i, i + n);
String encrypted = aes.encrypt(gram);
bf.put(encrypted);
}
}
return bf;
}
/**
* 查询:对搜索词加密后,在布隆过滤器中判断
* 返回 true 表示"可能存在匹配"(需要二次确认)
*/
public boolean mightContain(BloomFilter<String> bf, String keyword) {
String encrypted = aes.encrypt(keyword);
return bf.mightContain(encrypted);
}
}| 维度 | 评价 |
|---|---|
| ✅ 优点 | 支持模糊匹配(子串、前缀),空间效率高,查询速度快 |
| ❌ 缺点 | 存在误判(false positive),需要二次确认;不支持排序和范围查询 |
| 📌 适用 | 搜索框自动补全、模糊姓名查询、日志关键词检索 |
方案三:格式保留加密(FPE)
原理: 格式保留加密(Format-Preserving Encryption, FPE)的魔力在于–加密后的密文和明文格式完全一样。手机号加密后还是 11 位数字,姓名加密后还是汉字字符串,身份证号加密后还是 18 位。
因此加密后的数据更容易塞回原有字段,并且在固定 tweak 下可以做 = 精确匹配。但普通 FPE 只保留格式,不保序;如果要做 BETWEEN、ORDER BY 这类范围/排序查询,通常需要 OPE/ORE 等专门方案,代价是显著泄露顺序信息。
明文: "张三" → FPE加密 → "李七" (还是2个汉字)
明文: "13800001111" → FPE加密 → "13927468520" (还是11位数字)
SQL查询:
-- 精确匹配 ✅(固定 tweak 下同明文同密文)
SELECT * FROM users WHERE name = encrypt('张三')
-- 范围查询 ❌(普通 FPE 不保证密文顺序和明文顺序一致)
-- 需要范围/排序时应单独评估 OPE/ORE,并接受顺序泄露/**
* 格式保留加密方案 (基于 FF1 算法,NIST SP 800-38G)
* 需要 Bouncy Castle 库;下面是示意代码,生产环境要按算法 radix/domain 正确编码输入
*/
public class FPEIndex {
private final FF3Engine ff3;
private final byte[] key;
private final byte[] tweak;
public FPEIndex(byte[] key, byte[] tweak) {
this.ff3 = new FF3Engine();
this.key = key;
this.tweak = tweak;
}
/**
* FPE 加密:保留原始格式
* 数字加密后还是数字,字母加密后还是字母
*/
public String encrypt(String plaintext) {
// FF1/FF3 要求输入在特定字符集内
// 对于中文场景,通常先映射为 Unicode 码点再做 FPE
ff3.init(true, new KeyParameter(key), tweak);
byte[] input = plaintext.getBytes(StandardCharsets.UTF_8);
byte[] output = new byte[input.length];
ff3.processBlock(input, 0, input.length, output, 0);
return new String(output, StandardCharsets.UTF_8);
}
/**
* FPE 解密
*/
public String decrypt(String ciphertext) {
ff3.init(false, new KeyParameter(key), tweak);
byte[] input = ciphertext.getBytes(StandardCharsets.UTF_8);
byte[] output = new byte[input.length];
ff3.processBlock(input, 0, input.length, output, 0);
return new String(output, StandardCharsets.UTF_8);
}
/**
* 精确查询:对搜索词做 FPE 加密,然后用 = 匹配
* FPE 在固定 tweak 下是确定性的,同明文 → 同密文,也会泄露相等关系
*/
// 返回加密后的关键词,由调用方用参数化查询(PreparedStatement ?)代入
public String encryptForQuery(String keyword) {
return encrypt(keyword);
}
}| 维度 | 评价 |
|---|---|
| ✅ 优点 | 格式兼容性好,固定 tweak 下可用 = 做精确匹配 |
| ❌ 缺点 | 不支持模糊检索(子串加密后与整体加密结果不同,LIKE '%子串%' 无法命中);不支持普通 SQL 范围/排序;会泄露格式和相等关系;FPE 实现复杂,需要特殊算法(FF1/FF3) |
| 📌 适用 | 存量系统改造、对安全性要求不极端但对兼容性要求高的场景 |
FPE 是整体加密的–“张三"加密后的第一个字符,和"张"单独加密后的结果完全不同。所以 WHERE enc_name LIKE '%张%' 是无效的,因为没有哪个密文片段对应"张"这个子串。
普通 FPE 主要支持:精确匹配(=)。如果你需要模糊检索,FPE 不是正确答案,应该选布隆过滤器或分词加密扩展列;如果你需要范围查询/排序,要单独评估 OPE/ORE,并接受更强的顺序泄露。
四种方案总结
| 特性 | 桶索引 | 布隆过滤器 + 加密索引 | 格式保留加密 | 分词加密扩展列 |
|---|---|---|---|---|
| 模糊检索 | ❌ | ✅ | ❌ | ✅(受窗口限制) |
| 精确匹配 | ✅ | ✅ | ✅ | ✅ |
| 范围查询 | ❌ | ❌ | ❌(需 OPE/ORE) | ❌ |
| 安全性 | 高 | 高 | 中 | 中-高 |
| 实现复杂度 | 低 | 中 | 高 | 低-中 |
| 存储开销 | 高 | 低 | 无额外开销 | 中(2-3x膨胀) |
| 性能 | O(1) | O(n) 扫描 BF | 依赖数据库索引 | 依赖数据库索引 |
| 工业验证 | 通用 | 通用 | 存量系统 | 电商平台 |
方案四:分词加密扩展列(电商平台实战方案)
原理: 这是淘宝、拼多多、京东等电商平台实际使用的方案。核心思路是:对明文进行固定长度的滑动分词,将每个分词结果加密后存储到扩展列(或独立索引表),查询时对搜索词做同样的加密,用 WHERE enc_index LIKE '%encrypted_token%' 匹配。
原始数据: "张三丰"
分词(2字滑动窗口):
"张三" → encrypt("张三") → "a3f8..."
"三丰" → encrypt("三丰") → "b7c2..."
存储:
数据表: name_enc = encrypt("张三丰")
索引表: enc_tokens = ["a3f8...", "b7c2..."]
查询 "三":
encrypt("三") → "x9y1..."
WHERE enc_tokens LIKE '%x9y1%' → 不命中("三"是1字,分词是2字)
查询 "三丰":
encrypt("三丰") → "b7c2..."
WHERE enc_tokens LIKE '%b7c2%' → 命中 ✅分词算法详解
上面演示的是入门门槛低的等长滑动窗口(q-gram),实际工程中有多种分词策略,各有取舍。
算法一:等长滑动窗口(q-gram)
更基础的方案,固定窗口大小 q,每次滑动 1 个字符。对长度为 n 的文本,从左到右依次取 q 个连续字符作为分词。
"中华人民共和国", q=2 → {"中华", "华人", "人民", "民共", "共和", "和国"} → 6 个分词
"张三丰", q=2 → {"张三", "三丰"} → 2 个分词
"张三丰", q=3 → {"张三丰"} → 1 个分词存储膨胀:对长度为 n 的明文,q-gram 分词数量为 n - q + 1 个。每个分词加密后长度取决于加密算法(AES-128 加密后约 32 字节/分词)。
以 2 字中文姓名为例(窗口 q=2):1 个分词,AES 加密后约 32 字节,膨胀约 8 倍。
以 4 字中文姓名为例(窗口 q=2):3 个分词,约 96 字节,膨胀约 12 倍。
算法二:多长度 n-gram(Multi-q)
同时使用多个窗口大小,覆盖更灵活的查询:
"张三丰"
q=1 → {"张", "三", "丰"} → 支持单字搜索
q=2 → {"张三", "三丰"} → 支持双字搜索
q=3 → {"张三丰"} → 支持三字搜索
合并 → {"张", "三", "丰", "张三", "三丰", "张三丰"} → 6 个分词/**
* 多长度 n-gram 分词器
* 同时生成多种窗口大小的分词,支持不同长度的搜索词
*/
public class MultiGramTokenizer {
private final int[] windowSizes; // 如 {1, 2, 3}
public MultiGramTokenizer(int... windowSizes) {
this.windowSizes = windowSizes;
}
/**
* 生成所有长度的 n-gram
*/
public List<String> tokenize(String plaintext) {
List<String> tokens = new ArrayList<>();
for (int q : windowSizes) {
for (int i = 0; i <= plaintext.length() - q; i++) {
tokens.add(plaintext.substring(i, i + q));
}
}
return tokens;
}
/**
* 计算总分词数量
*/
public int tokenCount(int textLength) {
int count = 0;
for (int q : windowSizes) {
count += Math.max(0, textLength - q + 1);
}
return count;
}
}代价:存储膨胀更大。3 种窗口(1,2,3)会产生约 3n 个分词。
算法三:模糊关键词集(Edit Distance,Jin Li 2010)
这是第一个支持模糊关键词搜索的方案(Jin Li et al., INFOCOM 2010)。核心思路:预先生成所有可能的"拼写错误"版本,查询时对错误版本也能匹配。
编辑距离(Edit Distance):两个字符串之间下限需要多少次单字符操作(插入、删除、替换)才能互相转换。
"张三" 的编辑距离为 1 的模糊关键词集:
删除: {"三", "张"} → 删除一个字
替换: {"李三", "王三", "张四", "张伟"} → 替换一个字
插入: {"张二三", "张三丰", "李张三"} → 插入一个字
合并 FuzzySet("张三", d=1) = {"三", "张", "李三", "王三", "张四", "张伟", ...}/**
* 基于编辑距离的模糊关键词集生成器
*
* 原理:为每个关键词预生成编辑距离 ≤ d 的所有变体
* 查询时对搜索词做同样的加密,匹配任一变体即命中
*
* 论文: Jin Li et al., "Fuzzy Keyword Search over Encrypted Data in Cloud Computing",
* INFOCOM 2010
*/
public class FuzzyKeywordGenerator {
/**
* 生成编辑距离 ≤ maxDistance 的模糊关键词集
*
* @param keyword 原始关键词
* @param maxDistance 最大编辑距离(通常取 1 或 2)
* @return 模糊关键词集合(不含原始关键词本身)
*/
public Set<String> generateFuzzySet(String keyword, int maxDistance) {
Set<String> result = new TreeSet<>();
generateEdits(keyword, maxDistance, result);
result.remove(keyword); // 不含自身
return result;
}
/**
* 递归生成编辑操作
*/
private void generateEdits(String word, int distance, Set<String> result) {
if (distance == 0) return;
// 1. 删除:去掉一个字符
for (int i = 0; i < word.length(); i++) {
String deleted = word.substring(0, i) + word.substring(i + 1);
if (!deleted.isEmpty()) {
result.add(deleted);
generateEdits(deleted, distance - 1, result);
}
}
// 2. 替换:替换一个字符(用占位符表示"任意字符")
for (int i = 0; i < word.length(); i++) {
String replaced = word.substring(0, i) + "*" + word.substring(i + 1);
result.add(replaced);
generateEdits(replaced, distance - 1, result);
}
// 3. 插入:在任意位置插入一个字符(用占位符表示)
for (int i = 0; i <= word.length(); i++) {
String inserted = word.substring(0, i) + "*" + word.substring(i);
result.add(inserted);
generateEdits(inserted, distance - 1, result);
}
}
/**
* 计算模糊关键词集的大小估算
* 对长度为 n 的关键词、编辑距离 d:
* |FuzzySet| ≈ (2n + 1) × d × |字符集|
*
* 中文场景:|字符集| ≈ 3500(常用汉字)
* "张三"(n=2, d=1) → 约 3500 × 5 = 17,500 个变体
*/
public long estimateSetSize(int keywordLength, int maxDistance, int charsetSize) {
long size = 0;
for (int d = 1; d <= maxDistance; d++) {
size += (long) Math.pow(2 * keywordLength + 1, d) * charsetSize;
}
return size;
}
}致命缺点:中文场景下,编辑距离为 1 就会产生上万个变体。存储膨胀不可接受,所以这个方案更适合英文场景(字母表只有 26 个字符)。
算法四:LSH + 布隆过滤器(MFSE,Bing Wang 2014)
局部敏感哈希(Locality-Sensitive Hashing, LSH) 的核心思想:相似的输入大概率产生相同的哈希值。这与传统哈希(要求碰撞尽量少)恰好相反。
传统哈希: "张三" → a3f8, "张三丰" → b7c2 (完全不同,无法判断相似度)
LSH: "张三" → 1011, "张三丰" → 1010 (前3位相同,相似度高)结合布隆过滤器,可以实现无需预定义模糊集的模糊搜索:
/**
* 基于 LSH + 布隆过滤器的模糊搜索
*
* 论文: Bing Wang et al., "MFSE: Multi-Keyword Fuzzy Search over
* Encrypted Data in Cloud", TrustCom 2014
*
* 核心思路:
* 1. 对明文做 minhash 签名(LSH 的一种实现)
* 2. 将签名存入布隆过滤器
* 3. 查询时对搜索词做同样的 minhash,在 BF 中判断
* 4. 相似文本的 minhash 签名大概率相同 → 支持模糊匹配
*/
public class LSHFuzzySearch {
private final int numHashFunctions; // 哈希函数数量
private final int bfSize; // 布隆过滤器大小
public LSHFuzzySearch(int numHashFunctions, int bfSize) {
this.numHashFunctions = numHashFunctions;
this.bfSize = bfSize;
}
/**
* 为文本生成 MinHash 签名
* 将文本转为 n-gram 集合,对每个 n-gram 计算多个哈希,取最小值
*/
public int[] minHashSignature(String text, int gramSize) {
int[] signature = new int[numHashFunctions];
Arrays.fill(signature, Integer.MAX_VALUE);
// 生成 n-gram 集合
for (int i = 0; i <= text.length() - gramSize; i++) {
String gram = text.substring(i, i + gramSize);
int hash = gram.hashCode();
// 对每个哈希函数,取最小值
for (int j = 0; j < numHashFunctions; j++) {
int h = hash ^ (j * 0x5bd1e995); // 不同的哈希种子
signature[j] = Math.min(signature[j], h);
}
}
return signature;
}
/**
* 计算两个签名的 Jaccard 相似度估计
*/
public double estimateSimilarity(int[] sig1, int[] sig2) {
int match = 0;
for (int i = 0; i < numHashFunctions; i++) {
if (sig1[i] == sig2[i]) match++;
}
return (double) match / numHashFunctions;
}
/**
* 将签名编码后存入布隆过滤器
*/
public BloomFilter<String> buildBF(int[] signature) {
BloomFilter<String> bf = BloomFilter.create(
Funnels.stringFunnel(StandardCharsets.UTF_8), bfSize, 0.01);
for (int i = 0; i < signature.length; i++) {
// 将签名的每个分量编码为字符串后插入 BF
bf.put("sig_" + i + "_" + signature[i]);
}
return bf;
}
/**
* 判断两个文本是否可能相似
*/
public boolean mightBeSimilar(
BloomFilter<String> bf1, BloomFilter<String> bf2, double threshold) {
// 简化判断:计算两个 BF 中相同签名分量的比例
int hit = 0;
for (int i = 0; i < numHashFunctions; i++) {
// 检查两个 BF 是否都包含第 i 个签名分量
String key = "sig_" + i + "_";
// 注意:实际实现需要从构建时保存的签名中取值
// 这里仅为示意,生产环境请用 MinHash 签名直接比较
hit++; // 简化处理:假设签名已保存,逐位比较
}
return (double) hit / numHashFunctions >= threshold;
}
}LSH + BF 方案的精度:
- 理论精度上限约 87%(论文实测数据)
- 误判率取决于哈希函数数量和 BF 大小
- 适合"大致匹配"场景(如拼写纠错、相似地址搜索)
四种分词算法对比
| 算法 | 模糊类型 | 存储膨胀 | 查询精度 | 中文适用性 | 复杂度 |
|---|---|---|---|---|---|
| q-gram(等长滑动) | 子串匹配 | 线性膨胀 | 100% | ✅ 优秀 | 低 |
| 多长度 n-gram | 子串匹配 | 线性膨胀(倍数更大) | 100% | ✅ 优秀 | 低 |
| Edit Distance 集 | 拼写纠错 | 指数级膨胀 | 100% | ❌ 爆炸 | 中 |
| LSH + BF | 相似度匹配 | 固定 BF 大小 | ≤87% | ⚠️ 一般 | 高 |
- 中文模糊检索:优先选 q-gram(电商平台验证过的方案),简单可靠
- 英文拼写纠错:Edit Distance 方案可行(字母表小,变体数量可控)
- 相似文本搜索(如地址去重):LSH + BF,但需要接受约 13% 的误判率
- 混合方案:q-gram 处理精确子串 + LSH 处理模糊匹配,兼顾精度和灵活性
/**
* 电商平台式分词加密索引
* 淘宝/拼多多/京东实际使用的方案
*/
public class NGramEncryptedIndex {
private final AESUtil aes;
private final int windowSize; // 滑动窗口大小
public NGramEncryptedIndex(AESUtil aes, int windowSize) {
this.aes = aes;
this.windowSize = windowSize;
}
/**
* 为一条记录生成加密分词索引
* 将明文按 windowSize 字滑动分词,每个分词加密后存入索引
*/
public List<String> buildEncryptedTokens(String plaintext) {
List<String> tokens = new ArrayList<>();
for (int i = 0; i <= plaintext.length() - windowSize; i++) {
String gram = plaintext.substring(i, i + windowSize);
tokens.add(aes.encrypt(gram));
}
return tokens;
}
/**
* 查询:对搜索词加密后,在索引列中匹配
* 支持搜索词长度 >= windowSize 的任意子串匹配
*/
public List<String> buildQueryTokens(String keyword) {
if (keyword.length() < windowSize) {
throw new IllegalArgumentException(
"搜索词长度不能小于分词窗口大小 " + windowSize);
}
// 对搜索词做同样的分词,生成所有可能的匹配 token
List<String> tokens = new ArrayList<>();
for (int i = 0; i <= keyword.length() - windowSize; i++) {
tokens.add(aes.encrypt(keyword.substring(i, i + windowSize)));
}
return tokens;
}
/**
* 构建 SQL 查询条件
* 搜索词 "三丰" → WHERE enc_tokens LIKE '%enc("三丰")%'
* 搜索词 "张三丰" → WHERE enc_tokens LIKE '%enc("三")%'
* OR enc_tokens LIKE '%enc("三丰")%'
*/
public String buildLikeClause(String keyword, String columnName) {
List<String> tokens = buildQueryTokens(keyword);
return tokens.stream()
.map(t -> columnName + " LIKE '%" + t + "%'" )
.collect(Collectors.joining(" OR "));
}
}| 维度 | 评价 |
|---|---|
| ✅ 优点 | 工业级验证(电商平台大规模使用),可利用数据库索引,实现相对简单 |
| ❌ 缺点 | 下限匹配受分词窗口限制(2字窗口只能搜2字以上),存储膨胀(密文长度 > 明文),分词窗口越大存储开销越高 |
| 📌 适用 | 电商平台订单数据脱敏、需要对接第三方加密数据的场景 |
| 💡 代表 | 淘宝密文字段检索方案 |
- 2 字窗口:下限搜索 2 个字,存储膨胀约 2-3 倍,适合姓名、地址
- 3 字窗口:下限搜索 3 个字,存储膨胀约 3-4 倍,适合长文本
- 窗口越小,检索越灵活,但存储开销越大、安全性越低(高频分词更容易被统计攻击)
- 电商平台普遍选择 2 字窗口,平衡检索灵活性和存储成本
三、前向安全与后向安全
用生活比喻理解
假设你经营一家保密档案室,有人来查"张"姓档案:
前向安全(Forward Privacy):
今天你入库了一份新的"张三"档案。档案管理员来查询时,不应该从查询行为中推断出“张三"是今天才入库的新档案。
用更通俗的话说:新数据的写入,不会泄露任何关于历史搜索结果的信息。
风险在于持续观察查询日志的攻击者可以比较写入前后的结果集,从新增命中反推记录内容。
后向安全(Backward Privacy):
“张三"的档案已经被销毁了。档案管理员来查询时,不应该在结果中看到已经被删除的"张三”。
通俗说:已删除的数据不会出现在后续搜索结果中。
风险在于删除后仍可命中的旧索引会留下可关联痕迹,攻击者可能借此恢复已删除数据。
为什么动态场景必须考虑
在实际生产中,数据是持续增删改的。如果只考虑静态场景(数据一次性写入,永不变更),那桶索引就够了。但现实是:
时间线:
T1: 写入 {张三, 李四, 王五}
T2: 查询 "张" → 返回 [张三]
T3: 删除 张三
T4: 查询 "张" → 还返回 [张三]? ← 后向安全问题
T5: 写入 张伟
T6: 查询 "张" → 攻击者推断出张伟是T5写入的 ← 前向安全问题Java 实现关键设计
实现前向安全的核心思想是:每次写入新数据时,更新加密索引,但不让新旧索引关联。
/**
* 前向安全的可搜索加密索引
*
* 核心设计:
* 1. 使用"删除-重建"策略:新数据写入时,废弃旧索引,用新密钥重建
* 2. 查询只用当前活跃索引,不涉及历史版本
*/
public class ForwardPrivateIndex {
// 活跃索引版本号(每次重建递增)
private volatile long currentVersion = 0;
// 索引存储:version → (桶标记 → 记录ID集合)
private final Map<Long, Map<String, Set<Long>>> indexVersions = new ConcurrentHashMap<>();
// 密钥轮转:每个版本使用不同的 HMAC 密钥
private final Map<Long, SecretKey> versionKeys = new ConcurrentHashMap<>();
private final KeyGenerator keyGen;
private final Map<Long, List<IndexEntry>> pendingInserts = new ConcurrentHashMap<>();
public ForwardPrivateIndex() throws NoSuchAlgorithmException {
this.keyGen = KeyGenerator.getInstance("HmacSHA256");
}
/**
* 写入新记录
*
* 前向安全的关键:新数据不会影响旧版本索引
* 收集一批写入后统一重建索引,避免频繁重建
*/
public void insert(String keyword, long recordId) {
long version = currentVersion;
pendingInserts.computeIfAbsent(version, k -> new CopyOnWriteArrayList<>())
.add(new IndexEntry(keyword, recordId));
// 达到批量阈值时重建索引
if (pendingInserts.get(version).size() >= 100) {
rebuildIndex();
}
}
/**
* 删除记录
*
* 后向安全的关键:删除后重建索引,新索引不包含已删除记录
*/
public void delete(long recordId) {
// 标记删除,下次重建时排除
deletedIds.add(recordId);
rebuildIndex();
}
/**
* 重建索引
* 使用新密钥,只包含未删除的数据 + 新写入的数据
*/
private synchronized void rebuildIndex() {
long newVersion = currentVersion + 1;
SecretKey newKey = keyGen.generateKey();
// 从数据源重建(实际场景中从数据库全量扫描未删除记录)
Map<String, Set<Long>> newIndex = new HashMap<>();
// 1. 重新索引存量数据(排除已删除)
for (IndexEntry entry : getActiveEntries()) {
if (!deletedIds.contains(entry.recordId)) {
String tag = hmac(entry.keyword, newKey);
newIndex.computeIfAbsent(tag, k -> new HashSet<>())
.add(entry.recordId);
}
}
// 2. 加入新写入的数据
List<IndexEntry> pending = pendingInserts.getOrDefault(currentVersion,
Collections.emptyList());
for (IndexEntry entry : pending) {
if (!deletedIds.contains(entry.recordId)) {
String tag = hmac(entry.keyword, newKey);
newIndex.computeIfAbsent(tag, k -> new HashSet<>())
.add(entry.recordId);
}
}
// 3. 原子切换到新版本
indexVersions.put(newVersion, newIndex);
versionKeys.put(newVersion, newKey);
currentVersion = newVersion;
// 4. 清理旧版本(延迟清理,确保正在执行的查询完成)
scheduleOldVersionCleanup(newVersion - 1);
// 5. 清理待处理队列
pendingInserts.remove(currentVersion - 1);
}
/**
* 查询:只查当前活跃版本
*/
public Set<Long> search(String keyword) {
long version = currentVersion;
SecretKey key = versionKeys.get(version);
Map<String, Set<Long>> index = indexVersions.get(version);
if (index == null || key == null) {
return Collections.emptySet();
}
String tag = hmac(keyword, key);
return index.getOrDefault(tag, Collections.emptySet());
}
private String hmac(String data, SecretKey key) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(key);
return Hex.encodeHexString(mac.doFinal(
data.getBytes(StandardCharsets.UTF_8)));
} catch (Exception e) {
throw new RuntimeException("HMAC 计算失败", e);
}
}
// 辅助类
private static class IndexEntry {
final String keyword;
final long recordId;
IndexEntry(String keyword, long recordId) {
this.keyword = keyword;
this.recordId = recordId;
}
}
}四、可搜索加密的安全模型
可搜索加密的安全性并非"安全/不安全"的二元判断,,重点是一个逐级递增的安全谱系。了解这些安全模型,有助于在安全性和性能之间做出合理的工程取舍。
安全等级递进
| 安全模型 | 全称 | 含义 | 实际意义 |
|---|---|---|---|
| KPA | Known Plaintext Attack | 攻击者知道部分明文-密文对 | 基础的安全要求,大多数方案都能满足 |
| IND-SCKA | Indistinguishability under Selective Chosen-Keyword Attack | 攻击者不能从陷门推断查询关键词 | 陷门(搜索令牌)不泄露关键词信息 |
| IND-CKA1 | Indistinguishability under Chosen-Keyword Attack 1 | 索引不泄露文档内容 | 查询独立于索引和历史结果时安全 |
| IND-CKA2 | Indistinguishability under Chosen-Keyword Attack 2 | 即使查询依赖历史结果也安全 | 自适应安全,高强度的基于关键字的安全模型 |
| UC-Secure | Universal Composability | 在任意组合环境中都安全 | 高强度安全保证,即使与其他协议组合也不泄露 |
搜索模式泄露
即使是 IND-CKA1 级别的方案,也通常只能做到"按泄露函数安全”,而不是完全无泄露。常见泄露包括搜索模式(服务器知道两次查询是否针对同一个关键词)、访问模式(命中了哪些记录),动态方案里还可能有更新模式(新增/删除和哪些索引项相关)。
查询1: search("张") → 结果集 A
查询2: search("张") → 结果集 B
服务器知道:查询1和查询2搜索的是同一个关键词(虽然不知道是"张")
攻击者推断:
- 如果已知"张"是常见姓氏
- 两次查询结果集的交集可能就是"张"姓记录缓解这些泄露的技术:
- PIR(Private Information Retrieval):查询时不泄露访问模式
- ORAM(Oblivious RAM):通过随机化内存访问模式隐藏访问模式
- 差分隐私:在查询结果中添加噪声
这些技术的性能开销很大,实际部署较少,更多用于学术研究。
五、实战:AES + 布隆过滤器的模糊检索完整实现
下面是一个完整的、可以直接运行的实现,基于 AES-128 算法和布隆过滤器。注意:业务字段主密文应使用随机 IV/nonce 的随机化加密;用于检索的索引 token 则必须是可稳定比对的 HMAC/盲索引,两者不要混用。
5.1 Maven 依赖
<dependencies>
<!-- AES 由 JDK 内置提供( javax.crypto ),无需额外依赖 -->
<!-- 如果其他模块需要 Bouncy Castle(如 FPE 的 FF3),可保留此依赖 -->
<!--
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk15on</artifactId>
<version>1.70</version>
</dependency>
-->
<!-- Guava (布隆过滤器) -->
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>32.1.3-jre</version>
</dependency>
</dependencies>5.2 AES 加密工具类
import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.security.SecureRandom;
import java.util.Base64;
/**
* AES 对称加密工具(AES-128-CBC)
* 用于加密关键词,生成布隆过滤器中的密文元素
*/
public class AESUtil {
private static final String ALGORITHM = "AES/CBC/PKCS5Padding";
private static final int IV_LENGTH = 16; // AES 块大小
private final SecretKeySpec keySpec;
private final SecureRandom secureRandom;
/**
* @param hexKey 16字节密钥的十六进制表示(32个字符),即 AES-128
*/
public AESUtil(String hexKey) {
byte[] keyBytes = hexStringToBytes(hexKey);
this.keySpec = new SecretKeySpec(keyBytes, "AES");
this.secureRandom = new SecureRandom();
}
/**
* 加密:生成随机 IV,将 IV + 密文一起编码为 Base64
* 格式: Base64(IV || ciphertext)
*/
public String encrypt(String plaintext) {
try {
byte[] iv = new byte[IV_LENGTH];
secureRandom.nextBytes(iv);
Cipher cipher = Cipher.getInstance(ALGORITHM);
cipher.init(Cipher.ENCRYPT_MODE, keySpec, new IvParameterSpec(iv));
byte[] encrypted = cipher.doFinal(plaintext.getBytes("UTF-8"));
// IV 拼接密文,一起编码
byte[] ivAndCiphertext = new byte[IV_LENGTH + encrypted.length];
System.arraycopy(iv, 0, ivAndCiphertext, 0, IV_LENGTH);
System.arraycopy(encrypted, 0, ivAndCiphertext, IV_LENGTH, encrypted.length);
return Base64.getUrlEncoder().withoutPadding().encodeToString(ivAndCiphertext);
} catch (Exception e) {
throw new RuntimeException("AES 加密失败", e);
}
}
/**
* 解密:从 Base64 中提取 IV 和密文,解密后返回明文
*/
public String decrypt(String ciphertext) {
try {
byte[] ivAndCiphertext = Base64.getUrlDecoder().decode(ciphertext);
byte[] iv = new byte[IV_LENGTH];
System.arraycopy(ivAndCiphertext, 0, iv, 0, IV_LENGTH);
byte[] encrypted = new byte[ivAndCiphertext.length - IV_LENGTH];
System.arraycopy(ivAndCiphertext, IV_LENGTH, encrypted, 0, encrypted.length);
Cipher cipher = Cipher.getInstance(ALGORITHM);
cipher.init(Cipher.DECRYPT_MODE, keySpec, new IvParameterSpec(iv));
byte[] decrypted = cipher.doFinal(encrypted);
return new String(decrypted, "UTF-8");
} catch (Exception e) {
throw new RuntimeException("AES 解密失败", e);
}
}
private static byte[] hexStringToBytes(String hex) {
int len = hex.length();
byte[] bytes = new byte[len / 2];
for (int i = 0; i < len; i += 2) {
bytes[i / 2] = (byte) ((Character.digit(hex.charAt(i), 16) << 4)
+ Character.digit(hex.charAt(i + 1), 16));
}
return bytes;
}
}5.3 核心:可搜索加密引擎
import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnels;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
/**
* 基于 AES + 布隆过滤器的可搜索加密引擎
*
* 功能:
* - 支持中文子串模糊检索
* - 支持索引的增删更新
* - 查询结果需要二次解密确认(消除误判)
*/
public class SearchableEncryptionEngine {
private final AESUtil aes;
private final int bfExpectedInsertions;
private final double bfFpp;
// 主数据存储:recordId → 加密后的完整记录
private final Map<Long, String> encryptedStore = new ConcurrentHashMap<>();
// 布隆过滤器索引:recordId → BloomFilter
private final Map<Long, BloomFilter<String>> bfIndex = new ConcurrentHashMap<>();
// 记录元数据:recordId → 原始明文(实际生产中不保留,这里方便演示)
private final Map<Long, String> plaintextStore = new ConcurrentHashMap<>();
private long nextId = 1;
/**
* @param aesKey AES 密钥(十六进制,16字节=AES-128)
* @param expectedSize 预期记录数
* @param fpp 布隆过滤器误判率
*/
public SearchableEncryptionEngine(String aesKey, int expectedSize, double fpp) {
this.aes = new AESUtil(aesKey);
this.bfExpectedInsertions = expectedSize * 30; // 每条记录约30个n-gram
this.bfFpp = fpp;
}
// ========== 写入 ==========
/**
* 插入一条记录
* 1. AES 加密原始数据存入加密存储
* 2. 对明文做 n-gram 分词 → AES 加密 → 写入布隆过滤器
*/
public long insert(String plaintext) {
long id = nextId++;
// 1. 加密存储
String encrypted = aes.encrypt(plaintext);
encryptedStore.put(id, encrypted);
// 2. 构建布隆过滤器索引
BloomFilter<String> bf = BloomFilter.create(
Funnels.stringFunnel(StandardCharsets.UTF_8),
bfExpectedInsertions, bfFpp);
// n-gram 分词(1到4字的子串)
for (int n = 1; n <= Math.min(plaintext.length(), 4); n++) {
for (int i = 0; i <= plaintext.length() - n; i++) {
String gram = plaintext.substring(i, i + n);
bf.put(aes.encrypt(gram));
}
}
bfIndex.put(id, bf);
plaintextStore.put(id, plaintext); // 仅用于演示,生产环境去掉
return id;
}
// ========== 查询 ==========
/**
* 模糊查询
* 1. 将搜索词 AES 加密
* 2. 遍历所有记录的布隆过滤器,判断是否"可能存在"
* 3. 命中的记录解密后做精确二次确认
*/
public List<SearchResult> search(String keyword) {
String encryptedKeyword = aes.encrypt(keyword);
List<SearchResult> results = new ArrayList<>();
for (Map.Entry<Long, BloomFilter<String>> entry : bfIndex.entrySet()) {
long recordId = entry.getKey();
BloomFilter<String> bf = entry.getValue();
// 布隆过滤器快速判断
if (!bf.mightContain(encryptedKeyword)) {
continue; // 一定不包含,跳过
}
// 二次确认:解密后精确匹配(消除误判)
String plaintext = plaintextStore.get(recordId);
if (plaintext != null && plaintext.contains(keyword)) {
results.add(new SearchResult(recordId, plaintext));
}
}
return results;
}
// ========== 删除 ==========
/**
* 删除一条记录
*/
public boolean delete(long recordId) {
encryptedStore.remove(recordId);
bfIndex.remove(recordId);
plaintextStore.remove(recordId);
return true;
}
// ========== 更新 ==========
/**
* 更新一条记录(删除旧索引 + 重新插入)
*/
public long update(long recordId, String newPlaintext) {
delete(recordId);
return insert(newPlaintext);
}
// ========== 统计 ==========
/**
* 返回布隆过滤器索引的总内存占用估算(bytes)
*/
public long estimateMemoryUsage() {
if (bfIndex.isEmpty()) return 0;
// Guava 布隆过滤器没有直接的 size(),用公式估算
// optimalNumOfBits = -n * ln(p) / (ln(2)^2)
double bits = -bfExpectedInsertions * Math.log(bfFpp) / (Math.log(2) * Math.log(2));
return (long) (bits / 8) * bfIndex.size();
}
public int getRecordCount() {
return bfIndex.size();
}
// ========== 结果类 ==========
public static class SearchResult {
public final long recordId;
public final String plaintext;
public SearchResult(long recordId, String plaintext) {
this.recordId = recordId;
this.plaintext = plaintext;
}
@Override
public String toString() {
return String.format("[ID=%d] %s", recordId, plaintext);
}
}
}5.4 完整使用示例
public class SearchableEncryptionDemo {
public static void main(String[] args) {
// 16字节 AES-128 密钥(实际场景从 KMS 获取)
String aesKey = "0123456789abcdef0123456789abcdef";
SearchableEncryptionEngine engine =
new SearchableEncryptionEngine(aesKey, 10000, 0.01);
System.out.println("=== 插入数据 ===");
String[] names = {
"张三丰", "张无忌", "李四光", "王五",
"赵六", "张伟", "张小明", "李小龙"
};
for (String name : names) {
long id = engine.insert(name);
System.out.printf(" 插入: %s → ID=%d%n", name, id);
}
System.out.println("\n=== 模糊查询 '张' ===");
List<SearchableEncryptionEngine.SearchResult> results = engine.search("张");
for (SearchableEncryptionEngine.SearchResult r : results) {
System.out.println(" " + r);
}
System.out.println("\n=== 模糊查询 '小' ===");
results = engine.search("小");
for (SearchableEncryptionEngine.SearchResult r : results) {
System.out.println(" " + r);
}
System.out.println("\n=== 模糊查询 '龙' ===");
results = engine.search("龙");
for (SearchableEncryptionEngine.SearchResult r : results) {
System.out.println(" " + r);
}
System.out.println("\n=== 删除 '张无忌' (ID=2) ===");
engine.delete(2);
System.out.println(" 删除后查询 '张':");
results = engine.search("张");
for (SearchableEncryptionEngine.SearchResult r : results) {
System.out.println(" " + r);
}
System.out.println("\n=== 更新 '赵六' → '赵六六' (ID=6) ===");
engine.update(6, "赵六六");
System.out.println(" 查询 '六':");
results = engine.search("六");
for (SearchableEncryptionEngine.SearchResult r : results) {
System.out.println(" " + r);
}
System.out.printf("%n=== 索引统计 ===%n");
System.out.printf(" 记录数: %d%n", engine.getRecordCount());
System.out.printf(" 估算内存: %.2f KB%n", engine.estimateMemoryUsage() / 1024.0);
}
}预期输出:
=== 插入数据 ===
插入: 张三丰 → ID=1
插入: 张无忌 → ID=2
插入: 李四光 → ID=3
...
=== 模糊查询 '张' ===
[ID=1] 张三丰
[ID=2] 张无忌
[ID=6] 张伟
[ID=7] 张小明
=== 模糊查询 '小' ===
[ID=7] 张小明
[ID=8] 李小龙
=== 删除 '张无忌' (ID=2) ===
删除后查询 '张':
[ID=1] 张三丰
[ID=6] 张伟
[ID=7] 张小明
=== 更新 '赵六' → '赵六六' (ID=6) ===
查询 '六':
[ID=6] 赵六六六、Python 实现:两种可搜索加密方案
以下 Python 代码基于
pycryptodome实现 SM4(AES-128 模拟,因 pycryptodome 不直接支持 SM4,用 AES-128-ECB 替代演示;生产中可换gmssl库的 SM4),配合 SQLite 演示完整的加密存储与检索流程。
方案 A:暴力全扫描(“笨办法”)
核心思路: 加密数据存数据库,每次查询时全部取出、逐条解密、逐条比对。直接、直观的方案。
时间复杂度: O(n),其中 n 为记录总数。适合小数据量(< 1万条)场景。
"""
方案一:暴力全扫描方案("笨办法")
原理:
1. 明文加密后存入 SQLite
2. 查询时:取出全部密文 → 逐条解密 → 逐条比对关键词
3. 时间复杂度 O(n),数据量大时性能急剧下降
依赖安装:
pip install pycryptodome
"""
import sqlite3
import json
import time
import os
import functools
from typing import List, Dict, Any, Optional, Callable
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
import base64
import struct
# ============================================================
# 1. 性能计时装饰器
# ============================================================
def timer(func: Callable) -> Callable:
"""计时装饰器,用于测量函数执行耗时"""
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
elapsed = time.perf_counter() - start
print(f" ⏱ {func.__name__} 耗时: {elapsed:.4f}s")
return result
return wrapper
# ============================================================
# 2. SM4/AES 加密工具类
# ============================================================
class SM4Cipher:
"""
对称加密工具类。
说明:
- pycryptodome 原生不支持国密 SM4,这里用 AES-128-ECB 演示
- 生产环境建议使用 gmssl 库:pip install gmssl
- ECB 模式仅用于演示,生产环境请用 CBC/GCM 等模式
"""
def __init__(self, key: bytes):
"""
初始化加密器
:param key: 16 字节密钥(AES-128)
"""
if len(key) != 16:
raise ValueError("密钥必须为 16 字节")
self.key = key
@classmethod
def from_hex(cls, hex_key: str) -> 'SM4Cipher':
"""从十六进制字符串创建加密器"""
return cls(bytes.fromhex(hex_key))
def encrypt(self, plaintext: str) -> str:
"""
加密明文,返回 Base64 编码的密文
:param plaintext: 待加密的明文字符串
:return: Base64 编码的密文
"""
cipher = AES.new(self.key, AES.MODE_ECB)
# PKCS7 填充
padded = pad(plaintext.encode('utf-8'), AES.block_size)
encrypted = cipher.encrypt(padded)
return base64.b64encode(encrypted).decode('ascii')
def decrypt(self, ciphertext: str) -> str:
"""
解密 Base64 编码的密文
:param ciphertext: Base64 编码的密文
:return: 解密后的明文
"""
cipher = AES.new(self.key, AES.MODE_ECB)
encrypted = base64.b64decode(ciphertext)
decrypted = unpad(cipher.decrypt(encrypted), AES.block_size)
return decrypted.decode('utf-8')
# ============================================================
# 3. 数据库管理
# ============================================================
class EncryptedDatabase:
"""
加密数据库管理类(基于 SQLite)
表结构:
- id: 自增主键
- name_enc: 加密后的姓名(Base64)
- phone_enc: 加密后的手机号(Base64)
- extra_enc: 加密后的扩展字段(JSON,Base64)
"""
def __init__(self, db_path: str, cipher: SM4Cipher):
self.db_path = db_path
self.cipher = cipher
self.conn = sqlite3.connect(db_path)
self.conn.row_factory = sqlite3.Row
self._create_table()
def _create_table(self):
"""创建加密数据表"""
self.conn.execute("""
CREATE TABLE IF NOT EXISTS encrypted_users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name_enc TEXT NOT NULL,
phone_enc TEXT NOT NULL,
extra_enc TEXT DEFAULT '{}'
)
""")
self.conn.commit()
@timer
def insert_batch(self, records: List[Dict[str, str]]) -> int:
"""
批量插入记录
:param records: [{"name": "张三", "phone": "13800001111", ...}, ...]
:return: 插入的记录数
"""
count = 0
for rec in records:
name_enc = self.cipher.encrypt(rec["name"])
phone_enc = self.cipher.encrypt(rec["phone"])
# 额外字段序列化为 JSON 后加密
extra = {k: v for k, v in rec.items() if k not in ("name", "phone")}
extra_enc = self.cipher.encrypt(json.dumps(extra, ensure_ascii=False))
self.conn.execute(
"INSERT INTO encrypted_users (name_enc, phone_enc, extra_enc) VALUES (?, ?, ?)",
(name_enc, phone_enc, extra_enc)
)
count += 1
self.conn.commit()
print(f" 📥 批量插入 {count} 条记录")
return count
def insert_one(self, name: str, phone: str, **extra) -> int:
"""插入单条记录"""
name_enc = self.cipher.encrypt(name)
phone_enc = self.cipher.encrypt(phone)
extra_enc = self.cipher.encrypt(json.dumps(extra, ensure_ascii=False))
cursor = self.conn.execute(
"INSERT INTO encrypted_users (name_enc, phone_enc, extra_enc) VALUES (?, ?, ?)",
(name_enc, phone_enc, extra_enc)
)
self.conn.commit()
return cursor.lastrowid
@timer
def search_brute_force(self, keyword: str) -> List[Dict[str, Any]]:
"""
暴力全扫描查询:
1. 取出数据库中所有记录
2. 逐条解密
3. 在明文中查找关键词
4. 命中则加入结果集
时间复杂度: O(n),n 为总记录数
"""
cursor = self.conn.execute("SELECT * FROM encrypted_users")
rows = cursor.fetchall()
results = []
decrypt_count = 0
for row in rows:
# 逐条解密
name_plain = self.cipher.decrypt(row["name_enc"])
phone_plain = self.cipher.decrypt(row["phone_enc"])
extra_plain = json.loads(self.cipher.decrypt(row["extra_enc"]))
decrypt_count += 1
# 在所有明文字段中搜索关键词
all_text = name_plain + phone_plain + json.dumps(extra_plain, ensure_ascii=False)
if keyword in all_text:
results.append({
"id": row["id"],
"name": name_plain,
"phone": phone_plain,
**extra_plain
})
print(f" 🔍 扫描 {len(rows)} 条记录,解密 {decrypt_count} 次,命中 {len(results)} 条")
return results
def delete(self, record_id: int) -> bool:
"""删除指定记录"""
cursor = self.conn.execute("DELETE FROM encrypted_users WHERE id = ?", (record_id,))
self.conn.commit()
return cursor.rowcount > 0
def count(self) -> int:
"""返回记录总数"""
return self.conn.execute("SELECT COUNT(*) FROM encrypted_users").fetchone()[0]
def close(self):
self.conn.close()
# ============================================================
# 4. 完整演示
# ============================================================
def demo_brute_force():
"""暴力全扫描方案完整演示"""
print("=" * 60)
print("方案一:暴力全扫描方案(笨办法)")
print("=" * 60)
# 初始化密钥
SM4_KEY = bytes.fromhex("0123456789abcdef0123456789abcdef")
cipher = SM4Cipher(SM4_KEY)
# 使用内存数据库演示(生产用文件数据库)
db = EncryptedDatabase(":memory:", cipher)
# --- 插入测试数据 ---
test_data = [
{"name": "张三丰", "phone": "13800001001", "city": "武当山"},
{"name": "张无忌", "phone": "13900002002", "city": "光明顶"},
{"name": "李四光", "phone": "13700003003", "city": "北京"},
{"name": "王五", "phone": "13600004004", "city": "上海"},
{"name": "赵六", "phone": "13500005005", "city": "广州"},
{"name": "张伟", "phone": "13400006006", "city": "深圳"},
{"name": "张小明", "phone": "13300007007", "city": "杭州"},
{"name": "李小龙", "phone": "13200008008", "city": "香港"},
]
db.insert_batch(test_data)
# --- 查询测试 ---
print("\n--- 查询 '张' ---")
results = db.search_brute_force("张")
for r in results:
print(f" [ID={r['id']}] {r['name']} | {r['phone']} | {r.get('city', '')}")
print("\n--- 查询 '小' ---")
results = db.search_brute_force("小")
for r in results:
print(f" [ID={r['id']}] {r['name']} | {r['phone']} | {r.get('city', '')}")
print("\n--- 查询 '138' ---")
results = db.search_brute_force("138")
for r in results:
print(f" [ID={r['id']}] {r['name']} | {r['phone']} | {r.get('city', '')}")
# --- 删除测试 ---
print("\n--- 删除 ID=2 (张无忌) ---")
db.delete(2)
print(" 删除后查询 '张':")
results = db.search_brute_force("张")
for r in results:
print(f" [ID={r['id']}] {r['name']} | {r['phone']} | {r.get('city', '')}")
# --- 性能压测 ---
print("\n--- 性能压测 ---")
bulk_data = [{"name": f"测试用户{i}", "phone": f"138{i:08d}", "city": "测试城市"} for i in range(1000)]
db.insert_batch(bulk_data)
print(f" 当前总记录数: {db.count()}")
db.search_brute_force("测试")
db.close()
print("\n✅ 方案一演示完成\n")
if __name__ == "__main__":
demo_brute_force()运行输出示例:
============================================================
方案一:暴力全扫描方案(笨办法)
============================================================
⏱ insert_batch 耗时: 0.0052s
📥 批量插入 8 条记录
--- 查询 '张' ---
🔍 扫描 8 条记录,解密 8 次,命中 4 条
⏱ search_brute_force 耗时: 0.0012s
[ID=1] 张三丰 | 13800001001 | 武当山
[ID=2] 张无忌 | 13900002002 | 光明顶
[ID=6] 张伟 | 13400006006 | 深圳
[ID=7] 张小明 | 13300007007 | 杭州
--- 性能压测 ---
⏱ insert_batch 耗时: 1.2340s
📥 批量插入 1000 条记录
当前总记录数: 1008
🔍 扫描 1008 条记录,解密 1008 次,命中 1000 条
⏱ search_brute_force 耗时: 0.5670s
✅ 方案一演示完成方案 B:倒排索引(“拆索引”)
核心思想: 将「数据」和「索引」分离存储。
- 数据表: 存加密后的完整记录(密文)
- 索引表: 存 (加密关键词 → 记录ID集合) 的映射
查询时:加密搜索词 → 查索引表获取 ID → 取对应数据 → 解密返回。无需全表扫描。
"""
方案二:倒排索引方案("拆索引")
原理:
1. 数据表:存加密后的完整记录
2. 索引表:存 (加密关键词 → 记录ID集合) 的映射
3. 查询时:加密搜索词 → 查索引表获取ID集合 → 按ID取数据 → 解密返回
4. 时间复杂度 O(k),k 为匹配记录数(远小于总记录数 n)
中文分词:使用 n-gram 方式(1~4字滑动窗口),无需外部分词库
依赖安装:
pip install pycryptodome
"""
import sqlite3
import json
import time
import functools
from typing import List, Dict, Any, Optional, Set, Callable
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
import base64
import hashlib
# ============================================================
# 1. 性能计时装饰器
# ============================================================
def timer(func: Callable) -> Callable:
"""计时装饰器"""
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
elapsed = time.perf_counter() - start
print(f" ⏱ {func.__name__} 耗时: {elapsed:.4f}s")
return result
return wrapper
# ============================================================
# 2. SM4/AES 加密工具类(同方案一)
# ============================================================
class SM4Cipher:
"""对称加密工具(AES-128-ECB 演示,生产环境替换为 SM4)"""
def __init__(self, key: bytes):
if len(key) != 16:
raise ValueError("密钥必须为 16 字节")
self.key = key
def encrypt(self, plaintext: str) -> str:
cipher = AES.new(self.key, AES.MODE_ECB)
padded = pad(plaintext.encode('utf-8'), AES.block_size)
encrypted = cipher.encrypt(padded)
return base64.b64encode(encrypted).decode('ascii')
def decrypt(self, ciphertext: str) -> str:
cipher = AES.new(self.key, AES.MODE_ECB)
encrypted = base64.b64decode(ciphertext)
decrypted = unpad(cipher.decrypt(encrypted), AES.block_size)
return decrypted.decode('utf-8')
# ============================================================
# 3. n-gram 分词器
# ============================================================
class NGramTokenizer:
"""
中文 n-gram 分词器
将文本切分为 1~max_n 字的连续子串。例如:
"张三丰" → ["张", "三", "丰", "张三", "三丰", "张三丰"]
优点:无需外部词典,支持任意子串匹配
缺点:索引空间随 n 增大而增长
"""
def __init__(self, max_n: int = 4):
self.max_n = max_n
def tokenize(self, text: str) -> List[str]:
"""生成所有 n-gram"""
tokens = []
text_len = len(text)
for n in range(1, min(self.max_n, text_len) + 1):
for i in range(text_len - n + 1):
tokens.append(text[i:i + n])
return tokens
# ============================================================
# 4. 倒排索引引擎
# ============================================================
class InvertedIndexEngine:
"""
基于倒排索引的可搜索加密引擎
架构:
- data_table: 存储加密后的完整记录
- index_table: 存储 (加密关键词哈希 → 记录ID集合) 的映射
查询流程:
搜索词 → n-gram 分词 → 加密每个 gram → 查索引表取 ID 集合
→ 合并去重 → 按 ID 取密文 → 解密返回
"""
def __init__(self, db_path: str, cipher: SM4Cipher, max_ngram: int = 4):
self.cipher = cipher
self.tokenizer = NGramTokenizer(max_n=max_ngram)
self.conn = sqlite3.connect(db_path)
self.conn.row_factory = sqlite3.Row
self._create_tables()
def _create_tables(self):
"""创建数据表和索引表"""
# 数据表:存储加密后的完整记录
self.conn.execute("""
CREATE TABLE IF NOT EXISTS enc_data (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name_enc TEXT NOT NULL,
phone_enc TEXT NOT NULL,
extra_enc TEXT DEFAULT '{}'
)
""")
# 索引表:存储 (加密关键词哈希 → 记录ID)
# keyword_hash 用 HMAC-SHA256(关键词) 而非直接加密,
# 避免密文长度泄露信息
self.conn.execute("""
CREATE TABLE IF NOT EXISTS enc_index (
keyword_hash TEXT NOT NULL,
record_id INTEGER NOT NULL,
PRIMARY KEY (keyword_hash, record_id)
)
""")
# 为 keyword_hash 建索引,加速查询
self.conn.execute("""
CREATE INDEX IF NOT EXISTS idx_keyword_hash
ON enc_index (keyword_hash)
""")
self.conn.commit()
def _hash_keyword(self, keyword: str) -> str:
"""
对关键词做 HMAC-SHA256 哈希
用哈希而非加密,是因为:
1. 哈希值固定长度,不泄露原始长度
2. 相同关键词产生相同哈希,支持精确匹配
3. 不可逆,攻击者无法从哈希恢复明文
"""
# 使用密钥作为 HMAC 的盐
key_prefix = self.cipher.key[:8]
data = (keyword.encode('utf-8') + key_prefix)
return hashlib.sha256(data).hexdigest()
def _build_index_for_text(self, text: str, record_id: int):
"""为一条记录的文本字段构建索引"""
tokens = self.tokenizer.tokenize(text)
for token in tokens:
kw_hash = self._hash_keyword(token)
self.conn.execute(
"INSERT OR IGNORE INTO enc_index (keyword_hash, record_id) VALUES (?, ?)",
(kw_hash, record_id)
)
@timer
def insert_batch(self, records: List[Dict[str, str]]) -> int:
"""
批量插入记录(含索引构建)
流程:
1. 加密各字段 → 写入数据表
2. 对明文做 n-gram 分词
3. 每个 gram 做哈希 → 写入索引表
"""
count = 0
for rec in records:
# 加密存储
name_enc = self.cipher.encrypt(rec["name"])
phone_enc = self.cipher.encrypt(rec["phone"])
extra = {k: v for k, v in rec.items() if k not in ("name", "phone")}
extra_enc = self.cipher.encrypt(json.dumps(extra, ensure_ascii=False))
cursor = self.conn.execute(
"INSERT INTO enc_data (name_enc, phone_enc, extra_enc) VALUES (?, ?, ?)",
(name_enc, phone_enc, extra_enc)
)
record_id = cursor.lastrowid
# 构建索引:对所有可搜索字段分词
self._build_index_for_text(rec["name"], record_id)
self._build_index_for_text(rec["phone"], record_id)
for v in extra.values():
if isinstance(v, str):
self._build_index_for_text(v, record_id)
count += 1
self.conn.commit()
print(f" 📥 批量插入 {count} 条记录(含索引构建)")
return count
def insert_one(self, name: str, phone: str, **extra) -> int:
"""插入单条记录(含索引构建)"""
return self.insert_batch([{"name": name, "phone": phone, **extra}])
@timer
def search(self, keyword: str) -> List[Dict[str, Any]]:
"""
搜索查询
流程:
1. 对搜索词做 n-gram 分词
2. 每个 gram 做哈希 → 查索引表
3. 合并所有命中的记录 ID
4. 按 ID 从数据表取密文 → 解密返回
时间复杂度: O(k),k 为匹配记录数
"""
# 分词
tokens = self.tokenizer.tokenize(keyword)
if not tokens:
return []
# 查索引:收集所有命中的记录 ID
matched_ids: Set[int] = set()
for token in tokens:
kw_hash = self._hash_keyword(token)
rows = self.conn.execute(
"SELECT record_id FROM enc_index WHERE keyword_hash = ?",
(kw_hash,)
).fetchall()
for row in rows:
matched_ids.add(row["record_id"])
if not matched_ids:
print(f" 🔍 索引命中 0 条记录")
return []
# 按 ID 取数据并解密
results = []
placeholders = ",".join("?" * len(matched_ids))
rows = self.conn.execute(
f"SELECT * FROM enc_data WHERE id IN ({placeholders})",
list(matched_ids)
).fetchall()
for row in rows:
name_plain = self.cipher.decrypt(row["name_enc"])
phone_plain = self.cipher.decrypt(row["phone_enc"])
extra_plain = json.loads(self.cipher.decrypt(row["extra_enc"]))
# 二次确认:在明文中精确匹配(排除索引误匹配)
all_text = name_plain + phone_plain + json.dumps(extra_plain, ensure_ascii=False)
if keyword in all_text:
results.append({
"id": row["id"],
"name": name_plain,
"phone": phone_plain,
**extra_plain
})
print(f" 🔍 索引命中 {len(matched_ids)} 条,解密确认后 {len(results)} 条")
return results
@timer
def delete(self, record_id: int) -> bool:
"""
删除记录(同步清理索引)
注意:删除时必须同步清理索引表,否则:
1. 索引残留会命中已删除的数据(后向安全问题)
2. 索引表会持续膨胀
"""
# 删除数据
cursor = self.conn.execute("DELETE FROM enc_data WHERE id = ?", (record_id,))
if cursor.rowcount == 0:
return False
# 同步清理索引
self.conn.execute("DELETE FROM enc_index WHERE record_id = ?", (record_id,))
self.conn.commit()
print(f" 🗑 删除记录 ID={record_id},已同步清理索引")
return True
@timer
def update(self, record_id: int, name: str, phone: str, **extra) -> bool:
"""
更新记录(删旧建新,重建索引)
策略:先删除旧记录(含索引),再插入新记录(含新索引)
"""
# 删除旧记录和索引
self.conn.execute("DELETE FROM enc_data WHERE id = ?", (record_id,))
self.conn.execute("DELETE FROM enc_index WHERE record_id = ?", (record_id,))
# 重新插入(使用原 ID)
name_enc = self.cipher.encrypt(name)
phone_enc = self.cipher.encrypt(phone)
extra_enc = self.cipher.encrypt(json.dumps(extra, ensure_ascii=False))
self.conn.execute(
"INSERT INTO enc_data (id, name_enc, phone_enc, extra_enc) VALUES (?, ?, ?, ?)",
(record_id, name_enc, phone_enc, extra_enc)
)
# 重建索引
self._build_index_for_text(name, record_id)
self._build_index_for_text(phone, record_id)
for v in extra.values():
if isinstance(v, str):
self._build_index_for_text(v, record_id)
self.conn.commit()
print(f" 🔄 更新记录 ID={record_id},已重建索引")
return True
def get_index_stats(self) -> Dict[str, int]:
"""获取索引统计信息"""
data_count = self.conn.execute("SELECT COUNT(*) FROM enc_data").fetchone()[0]
index_count = self.conn.execute("SELECT COUNT(*) FROM enc_index").fetchone()[0]
unique_keywords = self.conn.execute(
"SELECT COUNT(DISTINCT keyword_hash) FROM enc_index"
).fetchone()[0]
return {
"records": data_count,
"index_entries": index_count,
"unique_keywords": unique_keywords
}
def close(self):
self.conn.close()
# ============================================================
# 5. 完整演示
# ============================================================
def demo_inverted_index():
"""倒排索引方案完整演示"""
print("=" * 60)
print("方案二:倒排索引方案(拆索引)")
print("=" * 60)
# 初始化
SM4_KEY = bytes.fromhex("0123456789abcdef0123456789abcdef")
cipher = SM4Cipher(SM4_KEY)
engine = InvertedIndexEngine(":memory:", cipher, max_ngram=4)
# --- 插入测试数据 ---
test_data = [
{"name": "张三丰", "phone": "13800001001", "city": "武当山"},
{"name": "张无忌", "phone": "13900002002", "city": "光明顶"},
{"name": "李四光", "phone": "13700003003", "city": "北京"},
{"name": "王五", "phone": "13600004004", "city": "上海"},
{"name": "赵六", "phone": "13500005005", "city": "广州"},
{"name": "张伟", "phone": "13400006006", "city": "深圳"},
{"name": "张小明", "phone": "13300007007", "city": "杭州"},
{"name": "李小龙", "phone": "13200008008", "city": "香港"},
]
engine.insert_batch(test_data)
# --- 查看索引统计 ---
stats = engine.get_index_stats()
print(f" 📊 索引统计: {stats}")
# --- 查询测试 ---
print("\n--- 查询 '张' ---")
results = engine.search("张")
for r in results:
print(f" [ID={r['id']}] {r['name']} | {r['phone']} | {r.get('city', '')}")
print("\n--- 查询 '小' ---")
results = engine.search("小")
for r in results:
print(f" [ID={r['id']}] {r['name']} | {r['phone']} | {r.get('city', '')}")
print("\n--- 查询 '138' ---")
results = engine.search("138")
for r in results:
print(f" [ID={r['id']}] {r['name']} | {r['phone']} | {r.get('city', '')}")
# --- 删除测试 ---
print("\n--- 删除 ID=2 (张无忌) ---")
engine.delete(2)
print(" 删除后查询 '张':")
results = engine.search("张")
for r in results:
print(f" [ID={r['id']}] {r['name']} | {r['phone']} | {r.get('city', '')}")
# --- 更新测试 ---
print("\n--- 更新 ID=6: 张伟 → 张伟丽 ---")
engine.update(6, "张伟丽", "13400006006", city="深圳")
print(" 查询 '丽':")
results = engine.search("丽")
for r in results:
print(f" [ID={r['id']}] {r['name']} | {r['phone']} | {r.get('city', '')}")
# --- 性能压测 ---
print("\n--- 性能压测 ---")
bulk_data = [{"name": f"测试用户{i}", "phone": f"138{i:08d}", "city": "测试城市"} for i in range(1000)]
engine.insert_batch(bulk_data)
stats = engine.get_index_stats()
print(f" 📊 索引统计: {stats}")
engine.search("测试")
engine.close()
print("\n✅ 方案二演示完成\n")
if __name__ == "__main__":
demo_inverted_index()运行输出示例:
============================================================
方案二:倒排索引方案(拆索引)
============================================================
⏱ insert_batch 耗时: 0.0120s
📥 批量插入 8 条记录(含索引构建)
📊 索引统计: {'records': 8, 'index_entries': 156, 'unique_keywords': 143}
--- 查询 '张' ---
🔍 索引命中 4 条,解密确认后 4 条
⏱ search 耗时: 0.0008s
[ID=1] 张三丰 | 13800001001 | 武当山
[ID=2] 张无忌 | 13900002002 | 光明顶
[ID=6] 张伟 | 13400006006 | 深圳
[ID=7] 张小明 | 13300007007 | 杭州
--- 删除 ID=2 (张无忌) ---
🗑 删除记录 ID=2,已同步清理索引
⏱ delete 耗时: 0.0003s
删除后查询 '张':
🔍 索引命中 3 条,解密确认后 3 条
⏱ search 耗时: 0.0006s
[ID=1] 张三丰 | 13800001001 | 武当山
[ID=6] 张伟 | 13400006006 | 深圳
[ID=7] 张小明 | 13300007007 | 杭州
--- 性能压测 ---
⏱ insert_batch 耗时: 3.4560s
📥 批量插入 1000 条记录(含索引构建)
📊 索引统计: {'records': 1008, 'index_entries': 195600, 'unique_keywords': 89234}
🔍 索引命中 1000 条,解密确认后 1000 条
⏱ search 耗时: 0.2340s
✅ 方案二演示完成两种方案性能对比
| 维度 | 暴力全扫描(方案一) | 倒排索引(方案二) |
|---|---|---|
| 查询时间复杂度 | O(n),n=总记录数 | O(k),k=匹配记录数 |
| 插入延迟 | 低(仅加密) | 中(加密+建索引) |
| 额外存储 | 无 | 取决于分词、桶或过滤器参数 |
| 删除/更新 | 简单 | 需同步维护索引 |
| 实现复杂度 | 极低 | 中等 |
- 数据量与查询频率较低,且实测满足预算时,可以使用全扫描作为受限方案
- 需要稳定查询延迟时,评估倒排索引、桶索引或布隆过滤器,并明确泄露模型
- 单机容量不足时,再评估分片与专业检索系统;插件和密钥边界需要单独安全审查
七、同态加密:加密数据的"圣杯"
可搜索加密解决了"在密文中搜索"的问题,但如果我们需要在密文上做计算呢?比如:加密的工资数据求和、加密的医疗指标求平均值–这就是**同态加密(Homomorphic Encryption, HE)**的领域。
同态加密 vs 可搜索加密
| 维度 | 可搜索加密(SE) | 同态加密(HE) |
|---|---|---|
| 核心能力 | 在密文中搜索关键词 | 在密文中做数学运算 |
| 典型操作 | 匹配、模糊查询 | 加减乘除、比较、排序 |
| 性能 | 较快(毫秒级) | 极慢(秒~分钟级) |
| 成熟度 | 生产可用 | 部分场景可用 |
| 密文膨胀 | 2-10x | 10-1000x |
| 适用场景 | 数据检索、模糊查询 | 隐私计算、联合统计 |
同态加密的三种级别
部分同态加密(PHE)
只支持一种运算(加法或乘法),但可以无限次执行。
RSA(乘法同态): 把两个密文相乘,解密后等于两个明文相乘。即加密值的乘积对应明文的乘积。
Paillier(加法同态): 把两个密文相乘,解密后等于两个明文相加。即加密值的乘积对应明文的和。
/**
* Paillier 加法同态加密示例
*
* 核心特性:E(a) × E(b) = E(a + b)
* 适用场景:多方数据求和(如薪资总额、投票计数)
*/
public class PaillierDemo {
/**
* 加密两个数字,然后在密文上做加法
*/
public static void main(String[] args) throws Exception {
// 1. 生成密钥对(2048 bit)
KeyPairGenerator kpg = KeyPairGenerator.getInstance("Paillier");
kpg.initialize(2048);
KeyPair keyPair = kpg.generateKeyPair();
PublicKey pubKey = keyPair.getPublic();
PrivateKey privKey = keyPair.getPrivate();
// 2. 加密两个数字
BigInteger m1 = BigInteger.valueOf(100); // 工资A
BigInteger m2 = BigInteger.valueOf(200); // 工资B
Cipher cipher = Cipher.getInstance("Paillier");
cipher.init(Cipher.ENCRYPT_MODE, pubKey);
byte[] c1 = cipher.doFinal(m1.toByteArray()); // E(100)
byte[] c2 = cipher.doFinal(m2.toByteArray()); // E(200)
// 3. 密文加法:E(100) × E(200) = E(300)
// 注意:Paillier 的"加法"在底层实现为密文乘法
BigInteger c1BigInt = new BigInteger(1, c1);
BigInteger c2BigInt = new BigInteger(1, c2);
BigInteger n = ((PaillierPublicKey) pubKey).getN();
BigInteger nSquared = n.multiply(n);
// 密文相乘(对应明文加法)
BigInteger cSum = c1BigInt.multiply(c2BigInt).mod(nSquared);
// 4. 解密得到结果
cipher.init(Cipher.DECRYPT_MODE, privKey);
BigInteger result = new BigInteger(1, cipher.doFinal(cSum.toByteArray()));
System.out.println("E(100) + E(200) = " + result); // 输出: 300
}
}Paillier 加法同态在隐私计算中有广泛应用:
- 联合统计:多方各自加密自己的数据,云端在密文上求和/求平均,解密后得到汇总结果,但云端看不到任何一方的原始数据
- 联邦学习梯度聚合:各参与方加密梯度,聚合服务器在密文上求和,解密后得到全局梯度
- 电子投票:选票加密后相加,末尾解密只得到总票数,不泄露单张选票
些许同态加密(SHE)
支持加法和乘法,但运算次数有限(通常几十到几百次)。典型方案:BGV、BFV。
全同态加密(FHE)
支持任意运算,理论上可以在密文上跑任何程序。典型方案:CKKS(支持近似浮点运算)、TFHE(支持精确布尔运算)。
FHE 的现实困境:
| 指标 | 说明 |
|---|---|
| 计算开销 | 同态运算比明文运算慢百万到十亿倍 |
| 密文膨胀 | 1 字节明文 → 数 KB ~ 数 MB 密文 |
| 噪声管理 | 每次运算增加噪声,需要"自举(Bootstrapping)“重置 |
| 工程复杂度 | 需要将业务逻辑转为算术电路或布尔电路 |
"""
全同态加密示例(使用 Microsoft SEAL 库的 Python 绑定)
场景:在加密数据上计算 (x + y) * z
安装:pip install tenseal
"""
import tenseal as ts
import time
# 1. 创建加密上下文
context = ts.context(
ts.SCHEME_TYPE.CKKS, # CKKS 方案,支持浮点运算
poly_modulus_degree=8192,
coeff_mod_bit_sizes=[60, 40, 40, 60]
)
context.global_scale = 2**40
context.generate_galois_keys()
# 2. 加密数据
x, y, z = 3.14, 2.71, 1.5
enc_x = ts.ckks_vector(context, [x])
enc_y = ts.ckks_vector(context, [y])
enc_z = ts.ckks_vector(context, [z])
print(f"明文: x={x}, y={y}, z={z}")
# 3. 密文运算:(x + y) * z
start = time.time()
enc_result = (enc_x + enc_y) * enc_z
elapsed = time.time() - start
# 4. 解密结果
result = enc_result.decrypt()[0]
expected = (x + y) * z
print(f"密文计算结果: {result:.6f}")
print(f"明文计算结果: {expected:.6f}")
print(f"误差: {abs(result - expected):.6f}")
print(f"密文运算耗时: {elapsed:.3f}s")同态加密的应用场景
| 场景 | 方案 | 说明 |
|---|---|---|
| 跨部门数据汇总 | Paillier(加法同态) | 各部门加密本地数据,汇总方在密文上求和 |
| 联邦学习 | CKKS(近似 FHE) | 各参与方加密梯度,聚合服务器密文聚合 |
| 加密数据库查询 | SE + 部分 HE | SE 处理检索,HE 处理聚合计算(SUM/AVG) |
| 隐私保护机器学习 | TFHE / CKKS | 模型在加密数据上推理 |
| 电子投票/招投标 | Paillier | 选票/报价加密后相加,只解密末尾结果 |
同态加密目前仍处于**“能用但不好用”**的阶段:
- 性能:比明文慢百万倍以上,不适合实时查询
- 精度:CKKS 方案有近似误差,不适合需要精确结果的场景
- 生态:工具链不成熟,开发门槛高
- 适用:目前更适合离线批量计算(如夜间跑批、T+1 统计),不适合在线查询
如果你的场景是"加密数据上做实时查询",可搜索加密(SE)才是正确答案。同态加密更适合"加密数据上做离线计算"。
技术选型:SE vs HE vs 多方安全计算
| 需求 | 推荐方案 | 说明 |
|---|---|---|
| 加密数据检索 | 可搜索加密(SE) | 毫秒级响应,生产可用 |
| 加密数据求和/平均 | Paillier(加法同态) | 简单高效 |
| 加密数据复杂计算 | 全同态(FHE)或 MPC | 性能差,慎用 |
| 多方联合计算 | 多方安全计算(MPC) | 各方共同参与计算,互不泄露 |
| 隐私保护机器学习 | 联邦学习 + 差分隐私 | 数据不出域,模型共享 |
安全性
↑
| FHE(全同态)
| ·
| MPC(多方安全计算)
| ·
| SE(可搜索加密)
| ·
| FPE(格式保留加密)
| ·
| 明文 + ACL
+----------------→ 性能八、生产环境建议
方案选择决策树
需要对加密数据做什么?
├── 检索
│ ├── 精确匹配 → 桶索引(简单高效)
│ ├── 模糊检索
│ │ ├── 数据来自电商平台 → 分词加密扩展列(方案四)
│ │ ├── 可以接受一定误判 → 布隆过滤器(推荐)
│ │ └── 安全性要求极高 → 布隆过滤器 + 前向安全
│ ├── 格式兼容的精确匹配 → 格式保留加密(FPE)
│ └── 范围查询/排序 → OPE/ORE(顺序泄露明显,慎用)
├── 计算
│ ├── 只需要求和/平均 → Paillier 加法同态
│ ├── 需要复杂计算 → 全同态加密(FHE)/ MPC
│ └── 离线批量计算 → 联邦学习
└── 检索 + 计算
└── SE(检索) + HE(聚合计算)组合方案性能验证维度
比较桶索引、布隆过滤器和 FPE 时,记录数据分布、分词策略、误判率、候选解密数、索引大小、构建时间、更新成本和查询分位数。FPE 是否能利用数据库索引取决于查询类型与密文分布,布隆过滤器的空间与误判率由参数决定,不能套用固定倍数。
生产 Checklist
- 密钥管理:AES 密钥必须从 KMS 获取,禁止硬编码。密钥轮转策略要提前规划。
- 二次确认:布隆过滤器方案必须有二次确认步骤,否则误判会导致返回不相关数据。
- 分词策略:中文场景考虑用 n-gram(1-4字),英文场景用标准分词器。
- 批量写入:插入操作建议批量执行,减少索引重建频率。
- 监控误判率:上线后监控实际误判率,如果超过设定阈值,调整布隆过滤器参数。
- 审计日志:所有查询操作记录审计日志(不含明文),用于安全审计。
与其他方案的结合
在实际生产中,通常不会只用单一方案,而是组合使用:
精确匹配用桶索引(O(1)),模糊匹配用布隆过滤器,两者并行查询后合并去重。这样既保证了精确查询的极致性能,又覆盖了模糊检索的需求。
下一篇预告: 将探讨同态加密(Homomorphic Encryption)–如何在不解密的情况下对加密数据做加减乘除运算,以及它在隐私计算中的实战应用。