SpringBoot实现加密字段模糊查询的最佳实践

 更新时间:2026年02月13日 08:57:58   作者:小沈同学呀  
在数据安全日益重要的今天,数据库加密已经成为企业级应用的标配,然而,加密字段的模糊查询一直是一个技术难题,本文将深入探讨Spring Boot环境下实现加密字段模糊查询的主流方案,结合理论分析和可直接用于生产的代码示例,需要的朋友可以参考下

前言

在数据安全日益重要的今天,数据库加密已经成为企业级应用的标配。然而,加密字段的模糊查询一直是一个技术难题。传统的加密方式(如AES)会将明文转换为完全随机的密文,导致无法直接使用LIKE语句进行模糊匹配。本文将深入探讨Spring Boot环境下实现加密字段模糊查询的主流方案,结合理论分析和可直接用于生产的代码示例,帮助开发者在保证数据安全的同时,兼顾业务查询需求

问题分析

1.1 传统加密的局限性

传统对称加密(如AES)和非对称加密(如RSA)都存在以下问题:

  • 加密后的密文与明文没有语义关联
  • 无法直接对密文进行模糊查询
  • 暴力破解风险高(尤其是短密码)

1.2 常见解决方案对比

方案优点缺点适用场景
明文存储查询性能好数据安全风险高非敏感数据
哈希加密安全性高无法反向解密密码存储
同态加密支持密文运算性能开销大高安全性要求场景
格式保留加密保持数据格式实现复杂需要保持数据格式的场景
部分加密平衡安全与性能部分数据暴露非核心敏感数据

主流技术方案

1 格式保留加密(Format-Preserving Encryption)

1.1 理论基础

加密特点:

  1. 密文与明文具有相同的格式(如都是数字字符串)
  2. 密文长度与明文长度完全一致
  3. 提供完整性保护
  4. 支持关联数据(AAD)

实现原理:
使用确定性加密算法对明文进行加密,然后使用哈希函数和模运算
将加密结果映射到与明文相同格式的字符集上,同时保存足够的信息以便解密。

1.2 依赖配置

增加pom配置依赖

<!-- pom.xml -->
<dependencies>
    <dependency>
        <groupId>com.google.crypto.tink</groupId>
        <artifactId>tink</artifactId>
        <version>1.10.0</version>
    </dependency>
</dependencies>

1.3 完整实现代码

FPE加密配置实现TrueFormatPreservingEncryption:

import com.google.crypto.tink.DeterministicAead;
import com.google.crypto.tink.KeysetHandle;
import com.google.crypto.tink.daead.DeterministicAeadConfig;
import com.google.crypto.tink.daead.DeterministicAeadFactory;
import com.google.crypto.tink.daead.DeterministicAeadKeyTemplates;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.List;

/**
 * 真正的格式保留加密实现
 * @author senfel
 * @date 2026/2/12 15:06
 */
public class TrueFormatPreservingEncryption {
    private final DeterministicAead daead;
    private final MessageDigest hashFunction;

    /**
     * 构造函数
     * @throws GeneralSecurityException 如果密钥生成或初始化失败
     */
    public TrueFormatPreservingEncryption() throws GeneralSecurityException {
        // 注册确定性 AEAD 配置
        DeterministicAeadConfig.register();
        // 创建 AES-SIV 密钥模板
        KeysetHandle keysetHandle = KeysetHandle.generateNew(
                DeterministicAeadKeyTemplates.AES256_SIV
        );
        // 获取确定性 AEAD 原语
        this.daead = DeterministicAeadFactory.getPrimitive(keysetHandle);
        // 初始化哈希函数用于格式转换
        try {
            this.hashFunction = MessageDigest.getInstance("SHA-256");
        } catch (NoSuchAlgorithmException e) {
            throw new GeneralSecurityException("无法初始化哈希函数", e);
        }
    }

    /**
     * 加密明文
     * @param plaintext 要加密的明文(如手机号、身份证号等)
     * @return 加密后的密文,与明文具有相同的格式和长度
     * @throws GeneralSecurityException 如果加密失败
     */
    public String encrypt(String plaintext) throws GeneralSecurityException {
        if (plaintext == null || plaintext.isEmpty()) {
            return plaintext;
        }
        // 将明文转换为字节数组
        byte[] plaintextBytes = plaintext.getBytes(StandardCharsets.UTF_8);
        // 使用确定性加密算法加密
        byte[] encryptedBytes = daead.encryptDeterministically(plaintextBytes, null);
        // 将加密结果转换为与原始格式相同的字符串
        // 使用加密后的字节数组作为种子,通过哈希和模运算生成格式保留的密文
        return convertToSameFormat(encryptedBytes, plaintext);
    }

    /**
     * 解密密文
     * @param ciphertext 要解密的密文(格式保留的字符串)
     * @param originalPlaintext 原始明文(用于确定格式)
     * @return 解密后的明文
     * @throws GeneralSecurityException 如果解密失败
     */
    public String decrypt(String ciphertext, String originalPlaintext) throws GeneralSecurityException {
        if (ciphertext == null || ciphertext.isEmpty()) {
            return ciphertext;
        }
        // 由于格式保留加密是不可逆的(信息丢失),我们需要使用暴力搜索或字典
        // 但这不是真正的格式保留加密的正确实现方式

        // 更好的方法:我们需要存储加密后的完整字节数组
        // 但为了保持格式,我们可以使用一个映射表

        // 实际上,真正的格式保留加密需要使用 FF1 或 FF3 算法
        // 这里我们提供一个改进的实现:使用加密后的字节数组的哈希值来生成格式保留的密文
        // 同时我们需要能够恢复原始加密字节数组
        
        throw new GeneralSecurityException("格式保留加密的解密需要原始加密上下文,当前实现不支持直接解密格式保留的密文");
    }

    /**
     * 改进的解密方法:需要存储完整的加密信息
     * 在实际应用中,应该将加密后的完整字节数组(Base64编码)与格式保留的密文一起存储
     * 
     * @param encryptedBytesBase64 Base64编码的加密字节数组
     * @return 解密后的明文
     * @throws GeneralSecurityException 如果解密失败
     */
    public String decryptFromBase64(String encryptedBytesBase64) throws GeneralSecurityException {
        if (encryptedBytesBase64 == null || encryptedBytesBase64.isEmpty()) {
            return "";
        }

        try {
            // 将Base64字符串解码为字节数组
            byte[] encryptedBytes = java.util.Base64.getDecoder().decode(encryptedBytesBase64);
            // 解密
            byte[] decrypted = daead.decryptDeterministically(encryptedBytes, null);
            return new String(decrypted, StandardCharsets.UTF_8);
        } catch (IllegalArgumentException e) {
            throw new GeneralSecurityException("Base64解码失败", e);
        }
    }

    /**
     * 改进的加密方法:返回格式保留的密文和Base64编码的完整加密数据
     * 
     * @param plaintext 要加密的明文
     * @return 包含格式保留密文和完整加密数据的对象
     * @throws GeneralSecurityException 如果加密失败
     */
    public EncryptionResult encryptWithFullData(String plaintext) throws GeneralSecurityException {
        if (plaintext == null || plaintext.isEmpty()) {
            return new EncryptionResult("", "");
        }
        // 将明文转换为字节数组
        byte[] plaintextBytes = plaintext.getBytes(StandardCharsets.UTF_8);
        // 使用确定性加密算法加密
        byte[] encryptedBytes = daead.encryptDeterministically(plaintextBytes, null);
        // 将加密结果转换为与原始格式相同的字符串
        String formatPreservingCiphertext = convertToSameFormat(encryptedBytes, plaintext);
        // 将完整的加密字节数组编码为Base64
        String encryptedBytesBase64 = java.util.Base64.getEncoder().encodeToString(encryptedBytes);
        return new EncryptionResult(formatPreservingCiphertext, encryptedBytesBase64);
    }

    /**
     * 将加密结果转换为与原始格式相同的字符串
     * 使用加密字节数组的哈希值作为随机种子,确保相同输入产生相同输出(确定性)
     * 
     * @param encryptedBytes 加密后的字节数组
     * @param plaintext 原始明文(用于确定格式)
     * @return 与原始格式相同的字符串
     */
    private String convertToSameFormat(byte[] encryptedBytes, String plaintext) {
        StringBuilder sb = new StringBuilder();
        // 使用加密字节数组生成确定性哈希值
        hashFunction.reset();
        hashFunction.update(encryptedBytes);
        byte[] hash = hashFunction.digest();
        // 为了处理长字符串,我们需要扩展哈希值
        List<Byte> hashBytes = new ArrayList<>();
        for (byte b : hash) {
            hashBytes.add(b);
        }
        // 如果哈希值不够长,循环使用
        int hashIndex = 0;
        for (int i = 0; i < plaintext.length(); i++) {
            char originalChar = plaintext.charAt(i);
            
            // 获取用于生成该字符的哈希字节
            byte hashByte = hashBytes.get(hashIndex % hashBytes.size());
            hashIndex++;
            
            // 为了增加随机性,也使用位置索引
            hashFunction.reset();
            hashFunction.update(encryptedBytes);
            hashFunction.update(String.valueOf(i).getBytes(StandardCharsets.UTF_8));
            byte[] positionHash = hashFunction.digest();
            int combinedValue = (hashByte & 0xFF) ^ (positionHash[0] & 0xFF);

            if (Character.isDigit(originalChar)) {
                // 数字格式:将哈希值映射到 0-9
                sb.append((char) ('0' + (combinedValue % 10)));
            } else if (Character.isLetter(originalChar)) {
                // 字母格式:将哈希值映射到字母
                int offset = Character.isUpperCase(originalChar) ? 'A' : 'a';
                sb.append((char) (offset + (combinedValue % 26)));
            } else {
                // 其他格式:保持原始字符(但这不是安全的,可以考虑加密)
                sb.append(originalChar);
            }
        }
        
        return sb.toString();
    }

    /**
     * 加密结果类
     * 包含格式保留的密文和完整的加密数据(Base64编码)
     */
    public static class EncryptionResult {
        private final String formatPreservingCiphertext; // 格式保留的密文
        private final String encryptedBytesBase64; // Base64编码的完整加密数据
        public EncryptionResult(String formatPreservingCiphertext, String encryptedBytesBase64) {
            this.formatPreservingCiphertext = formatPreservingCiphertext;
            this.encryptedBytesBase64 = encryptedBytesBase64;
        }
        public String getFormatPreservingCiphertext() {
            return formatPreservingCiphertext;
        }
        public String getEncryptedBytesBase64() {
            return encryptedBytesBase64;
        }
    }
}

1.4 测试用例

写一个简单的FPE测试用例:

/**
 * TrueFormatPreservingEncryption测试
 * @param args 命令行参数
 * @author senfel
 * @date 2026/2/12 15:06
 * @return void
 */
public static void main(String[] args) {
    try {
        TrueFormatPreservingEncryption fpe = new TrueFormatPreservingEncryption();
        String plaintext = "13800138000";

        System.out.println("=== 测试格式保留加密 ===");
        
        // 方法1:仅格式保留加密(无法解密,因为信息丢失)
        //明文: 13800138000
        //格式保留密文: 39508471166
        //明文长度: 11
        //密文长度: 11
        //明文格式是否保留: true
        String formatPreservingCiphertext = fpe.encrypt(plaintext);
        System.out.println("明文: " + plaintext);
        System.out.println("格式保留密文: " + formatPreservingCiphertext);
        System.out.println("明文长度: " + plaintext.length());
        System.out.println("密文长度: " + formatPreservingCiphertext.length());
        System.out.println("明文格式是否保留: " + isSameFormat(plaintext, formatPreservingCiphertext));
        System.out.println();

        // 方法2:格式保留加密 + 完整加密数据(可解密)
        EncryptionResult result = fpe.encryptWithFullData(plaintext);
        System.out.println("=== 使用完整数据加密 ===");
        //明文: 13800138000
        //格式保留密文: 39508471166
        //完整加密数据(Base64): AXRF5PpcdHbhqKhwTZAEkYwBlHrkQJ2J9PFRg8ApXtw=
        //格式是否保留: true
        //解密结果: 13800138000
        //加密解密是否成功: true
        System.out.println("明文: " + plaintext);
        System.out.println("格式保留密文: " + result.getFormatPreservingCiphertext());
        System.out.println("完整加密数据(Base64): " + result.getEncryptedBytesBase64());
        System.out.println("格式是否保留: " + isSameFormat(plaintext, result.getFormatPreservingCiphertext()));
        
        // 解密
        String decrypted = fpe.decryptFromBase64(result.getEncryptedBytesBase64());
        System.out.println("解密结果: " + decrypted);
        System.out.println("加密解密是否成功: " + plaintext.equals(decrypted));
        
        // 测试多次加密同一明文,验证确定性
        System.out.println();
        System.out.println("=== 测试确定性加密 ===");
        //第一次加密: 39508471166
        //第二次加密: 39508471166
        //两次加密结果是否相同(确定性): true
        String ciphertext1 = fpe.encrypt(plaintext);
        String ciphertext2 = fpe.encrypt(plaintext);
        System.out.println("第一次加密: " + ciphertext1);
        System.out.println("第二次加密: " + ciphertext2);
        System.out.println("两次加密结果是否相同(确定性): " + ciphertext1.equals(ciphertext2));
        
    } catch (GeneralSecurityException e) {
        System.err.println("加密解密失败: " + e.getMessage());
        e.printStackTrace();
    }
}

/**
 * 检查两个字符串是否具有相同的格式
 * @param s1 第一个字符串
 * @param s2 第二个字符串
 * @return 是否具有相同的格式
 */
private static boolean isSameFormat(String s1, String s2) {
    if (s1.length() != s2.length()) {
        return false;
    }
    for (int i = 0; i < s1.length(); i++) {
        char c1 = s1.charAt(i);
        char c2 = s2.charAt(i);
        if (Character.isDigit(c1) != Character.isDigit(c2)) {
            return false;
        }
        if (Character.isLetter(c1) != Character.isLetter(c2)) {
            return false;
        }
        if (Character.isUpperCase(c1) != Character.isUpperCase(c2)) {
            return false;
        }
    }
    return true;
}

2 AES部分加密与明文索引

2.1 理论基础

加密特点:

  1. 对敏感数据(如身份证号)进行部分加密
  2. 加密核心部分(前6位+后4位),保留中间部分作为明文索引
  3. 支持通过索引部分进行模糊查询,然后解密验证完整匹配

使用场景:
身份证号:加密前6位(地区码)+ 后4位(校验位),保留中间8位(出生日期)作为索引
手机号:可以加密前3位+后4位,保留中间部分作为索引

2.2 完整实现代码

增加一个用户类:

import lombok.Getter;
import javax.crypto.Cipher;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.Base64;

/**
 * AES部分加密与明文索引
 * @author senfel
 * @date 2026/2/12
 */
public class AESPartialEncryption {
    
    private static final String ALGORITHM = "AES";
    private static final String TRANSFORMATION = "AES";
    private final SecretKeySpec secretKey;
    
    /**
     * 构造函数
     *
     * @param key AES密钥(必须是16、24或32字节)
     * @throws IllegalArgumentException 如果密钥长度不正确
     */
    public AESPartialEncryption(String key) {
        if (key == null || key.isEmpty()) {
            throw new IllegalArgumentException("密钥不能为空");
        }
        byte[] keyBytes = key.getBytes(StandardCharsets.UTF_8);
        int keyLength = keyBytes.length;
        // AES密钥长度必须是16、24或32字节
        if (keyLength != 16 && keyLength != 24 && keyLength != 32) {
            throw new IllegalArgumentException(
                String.format("AES密钥长度必须是16、24或32字节,当前长度:%d", keyLength)
            );
        }
        this.secretKey = new SecretKeySpec(keyBytes, ALGORITHM);
    }
    
    /**
     * 使用默认密钥创建实例
     * 
     * @return AESPartialEncryption实例
     */
    public static AESPartialEncryption createDefault() {
        return new AESPartialEncryption("1234567890123456");
    }
    
    /**
     * 加密字符串
     * 
     * @param plaintext 要加密的明文
     * @return Base64编码的密文
     * @throws Exception 如果加密失败
     */
    public String encrypt(String plaintext) throws Exception {
        if (plaintext == null || plaintext.isEmpty()) {
            return plaintext;
        }
        Cipher cipher = Cipher.getInstance(TRANSFORMATION);
        cipher.init(Cipher.ENCRYPT_MODE, secretKey);
        byte[] encryptedBytes = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8));
        return Base64.getEncoder().encodeToString(encryptedBytes);
    }
    
    /**
     * 解密字符串
     * 
     * @param ciphertext Base64编码的密文
     * @return 解密后的明文
     * @throws Exception 如果解密失败
     */
    public String decrypt(String ciphertext) throws Exception {
        if (ciphertext == null || ciphertext.isEmpty()) {
            return ciphertext;
        }
        Cipher cipher = Cipher.getInstance(TRANSFORMATION);
        cipher.init(Cipher.DECRYPT_MODE, secretKey);
        byte[] decryptedBytes = cipher.doFinal(Base64.getDecoder().decode(ciphertext));
        return new String(decryptedBytes, StandardCharsets.UTF_8);
    }
    
    /**
     * 部分加密身份证号
     * 加密前6位(地区码)+ 后4位(校验位),保留中间8位(出生日期)作为索引
     * 
     * @param idCard 身份证号(18位)
     * @return 加密结果对象,包含加密的核心部分和明文索引部分
     * @throws Exception 如果加密失败或身份证号格式不正确
     */
    public IdCardEncryptionResult encryptIdCard(String idCard) throws Exception {
        if (idCard == null || idCard.length() != 18) {
            throw new IllegalArgumentException("身份证号必须是18位");
        }
        // 提取核心部分:前6位 + 后4位
        String corePart = idCard.substring(0, 6) + idCard.substring(14);
        // 加密核心部分
        String encryptedPart = encrypt(corePart);
        // 保留中间部分作为索引:第7-14位(出生日期)
        String indexPart = idCard.substring(6, 14);
        return new IdCardEncryptionResult(encryptedPart, indexPart);
    }
    
    /**
     * 解密身份证号
     * 
     * @param encryptedPart 加密的核心部分
     * @param indexPart 明文索引部分(出生日期)
     * @return 完整的身份证号
     * @throws Exception 如果解密失败
     */
    public String decryptIdCard(String encryptedPart, String indexPart) throws Exception {
        if (encryptedPart == null || encryptedPart.isEmpty()) {
            throw new IllegalArgumentException("加密部分不能为空");
        }
        if (indexPart == null || indexPart.length() != 8) {
            throw new IllegalArgumentException("索引部分必须是8位(出生日期)");
        }
        // 解密核心部分
        String decryptedCore = decrypt(encryptedPart);
        if (decryptedCore.length() != 10) {
            throw new IllegalArgumentException("解密后的核心部分长度不正确");
        }
        // 组合完整身份证号:前6位 + 中间8位(索引) + 后4位
        return decryptedCore.substring(0, 6) + indexPart + decryptedCore.substring(6);
    }
    
    /**
     * 部分加密手机号
     * 加密前3位(运营商号段)+ 后4位(用户号),保留中间部分作为索引
     * 
     * @param phone 手机号(11位)
     * @return 加密结果对象
     * @throws Exception 如果加密失败或手机号格式不正确
     */
    public PhoneEncryptionResult encryptPhone(String phone) throws Exception {
        if (phone == null || phone.length() != 11) {
            throw new IllegalArgumentException("手机号必须是11位");
        }
        // 提取核心部分:前3位 + 后4位
        String corePart = phone.substring(0, 3) + phone.substring(7);
        // 加密核心部分
        String encryptedPart = encrypt(corePart);
        // 保留中间部分作为索引:第4-7位
        String indexPart = phone.substring(3, 7);
        return new PhoneEncryptionResult(encryptedPart, indexPart);
    }
    
    /**
     * 解密手机号
     * 
     * @param encryptedPart 加密的核心部分
     * @param indexPart 明文索引部分
     * @return 完整的手机号
     * @throws Exception 如果解密失败
     */
    public String decryptPhone(String encryptedPart, String indexPart) throws Exception {
        if (encryptedPart == null || encryptedPart.isEmpty()) {
            throw new IllegalArgumentException("加密部分不能为空");
        }
        if (indexPart == null || indexPart.length() != 4) {
            throw new IllegalArgumentException("索引部分必须是4位");
        }
        // 解密核心部分
        String decryptedCore = decrypt(encryptedPart);
        if (decryptedCore.length() != 7) {
            throw new IllegalArgumentException("解密后的核心部分长度不正确");
        }
        // 组合完整手机号:前3位 + 中间4位(索引) + 后4位
        return decryptedCore.substring(0, 3) + indexPart + decryptedCore.substring(3);
    }
    

    /**
     * 身份证号加密结果
     */
    @Getter
    public static class IdCardEncryptionResult {
        private final String encryptedPart; // 加密的核心部分(Base64编码)
        private final String indexPart;     // 明文索引部分(出生日期,8位)
        
        public IdCardEncryptionResult(String encryptedPart, String indexPart) {
            this.encryptedPart = encryptedPart;
            this.indexPart = indexPart;
        }

        @Override
        public String toString() {
            return String.format("IdCardEncryptionResult{encryptedPart='%s', indexPart='%s'}", 
                encryptedPart, indexPart);
        }
    }
    
    /**
     * 手机号加密结果
     */
    @Getter
    public static class PhoneEncryptionResult {
        private final String encryptedPart; // 加密的核心部分(Base64编码)
        private final String indexPart;     // 明文索引部分(4位)
        
        public PhoneEncryptionResult(String encryptedPart, String indexPart) {
            this.encryptedPart = encryptedPart;
            this.indexPart = indexPart;
        }

        @Override
        public String toString() {
            return String.format("PhoneEncryptionResult{encryptedPart='%s', indexPart='%s'}", 
                encryptedPart, indexPart);
        }
    }
}

2.3 测试用例

增加AES部分加密测试用例:

/**
 * AES部分加密测试
 * @param args
 * @author senfel
 * @date 2026/2/12 15:33
 * @return void
 */
public static void main(String[] args) {
    try {
        // 创建AES加密服务
        AESPartialEncryption aesService = new AESPartialEncryption("1234567890123456");
        
        System.out.println("=== 测试身份证号部分加密 ===");
        String idCard = "110101199001011234";
        System.out.println("原始身份证号: " + idCard);
        
        // 加密
        IdCardEncryptionResult idCardResult = aesService.encryptIdCard(idCard);
        //加密结果: IdCardEncryptionResult{encryptedPart='8FiTU/zAnjzz4fJey48+Fw==', indexPart='19900101'}
        //加密的核心部分: 8FiTU/zAnjzz4fJey48+Fw==
        //明文索引部分(出生日期): 19900101
        System.out.println("加密结果: " + idCardResult);
        System.out.println("加密的核心部分: " + idCardResult.getEncryptedPart());
        System.out.println("明文索引部分(出生日期): " + idCardResult.getIndexPart());
        
        // 解密
        String decryptedIdCard = aesService.decryptIdCard(
            idCardResult.getEncryptedPart(), 
            idCardResult.getIndexPart()
        );
        //解密后的身份证号: 110101199001011234
        //加密解密是否成功: true
        System.out.println("解密后的身份证号: " + decryptedIdCard);
        System.out.println("加密解密是否成功: " + idCard.equals(decryptedIdCard));
        
        System.out.println("\n=== 测试手机号部分加密 ===");
        String phone = "13800138000";
        System.out.println("原始手机号: " + phone);
        
        // 加密
        PhoneEncryptionResult phoneResult = aesService.encryptPhone(phone);
        //加密结果: PhoneEncryptionResult{encryptedPart='Dhf5t5O0Tn1sSwUKB/WKyg==', indexPart='0013'}
        //加密的核心部分: Dhf5t5O0Tn1sSwUKB/WKyg==
        //明文索引部分: 0013
        System.out.println("加密结果: " + phoneResult);
        System.out.println("加密的核心部分: " + phoneResult.getEncryptedPart());
        System.out.println("明文索引部分: " + phoneResult.getIndexPart());
        
        // 解密
        String decryptedPhone = aesService.decryptPhone(
            phoneResult.getEncryptedPart(), 
            phoneResult.getIndexPart()
        );
        //解密后的手机号: 13800138000
        //加密解密是否成功: true
        System.out.println("解密后的手机号: " + decryptedPhone);
        System.out.println("加密解密是否成功: " + phone.equals(decryptedPhone));
        
        System.out.println("\n=== 测试通用加密解密 ===");
        String plaintext = "HelloWorld123456";
        System.out.println("原始文本: " + plaintext);

        String encrypted = aesService.encrypt(plaintext);
        //加密后: d0Ae9K3M+jCUTX227btKawUBh6DN5amHLLqwkatz5VM=
        System.out.println("加密后: " + encrypted);
        
        String decrypted = aesService.decrypt(encrypted);
        //解密后: HelloWorld123456
        //加密解密是否成功: true
        System.out.println("解密后: " + decrypted);
        System.out.println("加密解密是否成功: " + plaintext.equals(decrypted));
        
    } catch (Exception e) {
        System.err.println("测试失败: " + e.getMessage());
        e.printStackTrace();
    }
}

3 可搜索加密(Searchable Encryption)

3.1 理论基础

加密特点:

  1. 支持对文档进行加密,同时生成可搜索的索引
  2. 支持在不解密文档的情况下,通过关键词进行搜索
  3. 使用对称加密(AES)和HMAC实现可搜索加密

实现原理:
加密文档时,提取关键词并为每个关键词生成加密的索引token
搜索时,使用关键词生成trapdoor(陷阱门),与索引token匹配
匹配成功则说明文档包含该关键词,无需解密整个文档

使用场景:
加密邮件搜索
加密数据库查询
云存储中的加密文档搜索

3.2 完整实现代码

可搜索加密SearchableEncryption:

import lombok.Getter;
import javax.crypto.Cipher;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.*;

/**
 * 可搜索加密
 * @author senfel
 * @version 2.0
 * @date 2026/2/12
 */
public class SearchableEncryption {
    
    private static final String ALGORITHM = "AES";
    private static final String TRANSFORMATION = "AES";
    private static final String HMAC_ALGORITHM = "HmacSHA256";
    // 用于加密文档的密钥
    private final SecretKeySpec encryptionKey;
    // 用于生成索引的密钥
    private final SecretKeySpec indexKey;
    
    /**
     * 构造函数
     * @param encryptionKey 加密密钥(16、24或32字节)
     * @param indexKey 索引密钥(用于生成搜索索引)
     * @throws IllegalArgumentException 如果密钥长度不正确
     */
    public SearchableEncryption(String encryptionKey, String indexKey) {
        if (encryptionKey == null || encryptionKey.isEmpty()) {
            throw new IllegalArgumentException("加密密钥不能为空");
        }
        if (indexKey == null || indexKey.isEmpty()) {
            throw new IllegalArgumentException("索引密钥不能为空");
        }
        byte[] encKeyBytes = encryptionKey.getBytes(StandardCharsets.UTF_8);
        byte[] idxKeyBytes = indexKey.getBytes(StandardCharsets.UTF_8);
        validateKeyLength(encKeyBytes.length, "加密密钥");
        validateKeyLength(idxKeyBytes.length, "索引密钥");
        this.encryptionKey = new SecretKeySpec(encKeyBytes, ALGORITHM);
        this.indexKey = new SecretKeySpec(idxKeyBytes, HMAC_ALGORITHM);
    }
    
    /**
     * 使用默认密钥创建实例
     * @return SearchableEncryption实例
     */
    public static SearchableEncryption createDefault() {
        return new SearchableEncryption(
            "1234567890123456",  // 16字节加密密钥
            "abcdefghijklmnop"   // 16字节索引密钥
        );
    }
    
    /**
     * 验证密钥长度
     */
    private void validateKeyLength(int length, String keyName) {
        if (length != 16 && length != 24 && length != 32) {
            throw new IllegalArgumentException(
                String.format("%s长度必须是16、24或32字节,当前长度:%d", keyName, length)
            );
        }
    }
    
    /**
     * 加密文档
     * @param plaintext 明文文档
     * @param keywords 关键词列表(用于生成搜索索引)
     * @return 加密结果,包含加密文档和索引token列表
     * @throws Exception 如果加密失败
     */
    public EncryptionResult encrypt(String plaintext, Set<String> keywords) throws Exception {
        if (plaintext == null || plaintext.isEmpty()) {
            throw new IllegalArgumentException("明文不能为空");
        }
        if (keywords == null || keywords.isEmpty()) {
            keywords = extractKeywords(plaintext);
        }
        // 加密文档
        String encryptedDocument = encryptDocument(plaintext);
        // 为每个关键词生成索引token
        List<String> indexTokens = new ArrayList<>();
        for (String keyword : keywords) {
            String token = generateIndexToken(keyword);
            indexTokens.add(token);
        }
        return new EncryptionResult(encryptedDocument, indexTokens);
    }
    
    /**
     * 加密文档(不提取关键词,需要手动提供)
     * @param plaintext 明文文档
     * @return Base64编码的密文
     * @throws Exception 如果加密失败
     */
    public String encryptDocument(String plaintext) throws Exception {
        if (plaintext == null || plaintext.isEmpty()) {
            return plaintext;
        }
        Cipher cipher = Cipher.getInstance(TRANSFORMATION);
        cipher.init(Cipher.ENCRYPT_MODE, encryptionKey);
        byte[] plaintextBytes = plaintext.getBytes(StandardCharsets.UTF_8);
        byte[] encryptedBytes = cipher.doFinal(plaintextBytes);
        return Base64.getEncoder().encodeToString(encryptedBytes);
    }
    
    /**
     * 解密文档
     * @param encryptedDocument Base64编码的密文
     * @return 解密后的明文
     * @throws Exception 如果解密失败
     */
    public String decryptDocument(String encryptedDocument) throws Exception {
        if (encryptedDocument == null || encryptedDocument.isEmpty()) {
            return encryptedDocument;
        }
        Cipher cipher = Cipher.getInstance(TRANSFORMATION);
        cipher.init(Cipher.DECRYPT_MODE, encryptionKey);
        byte[] encryptedBytes = Base64.getDecoder().decode(encryptedDocument);
        byte[] decryptedBytes = cipher.doFinal(encryptedBytes);
        return new String(decryptedBytes, StandardCharsets.UTF_8);
    }
    
    /**
     * 为关键词生成索引token
     * 使用HMAC生成确定性的token,相同关键词总是生成相同的token
     * @param keyword 关键词
     * @return Base64编码的索引token
     * @throws Exception 如果生成失败
     */
    public String generateIndexToken(String keyword) throws Exception {
        if (keyword == null || keyword.isEmpty()) {
            throw new IllegalArgumentException("关键词不能为空");
        }
        Mac mac = Mac.getInstance(HMAC_ALGORITHM);
        mac.init(indexKey);
        byte[] keywordBytes = keyword.toLowerCase().trim().getBytes(StandardCharsets.UTF_8);
        byte[] tokenBytes = mac.doFinal(keywordBytes);
        return Base64.getEncoder().encodeToString(tokenBytes);
    }
    
    /**
     * 生成搜索陷阱门(Trapdoor)
     * 与索引token使用相同的生成方法,确保可以匹配
     *
     * @param keyword 搜索关键词
     * @return Base64编码的trapdoor
     * @throws Exception 如果生成失败
     */
    public String generateTrapdoor(String keyword) throws Exception {
        // Trapdoor与IndexToken使用相同的生成方法
        return generateIndexToken(keyword);
    }
    
    /**
     * 搜索加密文档
     * 通过比较trapdoor和索引token来判断文档是否包含关键词
     * 
     * @param indexTokens 文档的索引token列表
     * @param trapdoor 搜索关键词的trapdoor
     * @return 如果文档包含该关键词返回true
     */
    public boolean search(List<String> indexTokens, String trapdoor) {
        if (indexTokens == null || indexTokens.isEmpty() || trapdoor == null || trapdoor.isEmpty()) {
            return false;
        }
        // 直接比较trapdoor和索引token
        return indexTokens.contains(trapdoor);
    }
    
    /**
     * 批量搜索多个关键词
     * 
     * @param indexTokens 文档的索引token列表
     * @param keywords 搜索关键词列表
     * @return 匹配的关键词集合
     * @throws Exception 如果生成trapdoor失败
     */
    public Set<String> searchMultiple(List<String> indexTokens, Set<String> keywords) throws Exception {
        Set<String> matchedKeywords = new HashSet<>();
        if (indexTokens == null || indexTokens.isEmpty() || keywords == null || keywords.isEmpty()) {
            return matchedKeywords;
        }
        for (String keyword : keywords) {
            String trapdoor = generateTrapdoor(keyword);
            if (search(indexTokens, trapdoor)) {
                matchedKeywords.add(keyword);
            }
        }
        return matchedKeywords;
    }
    
    /**
     * 从文本中提取关键词(简单实现)
     * 实际应用中可以使用更复杂的分词和停用词过滤
     * 
     * @param text 文本内容
     * @return 关键词集合
     */
    private Set<String> extractKeywords(String text) {
        Set<String> keywords = new HashSet<>();
        if (text == null || text.isEmpty()) {
            return keywords;
        }
        // 简单的关键词提取:按空格和标点符号分割
        String[] words = text.toLowerCase()
            .replaceAll("[^\\p{L}\\p{N}\\s]", " ")
            .split("\\s+");
        
        // 过滤掉太短的词(少于2个字符)
        for (String word : words) {
            if (word.length() >= 2) {
                keywords.add(word.trim());
            }
        }
        
        return keywords;
    }
    
    /**
     * 加密结果类
     */
    @Getter
    public static class EncryptionResult {
        private final String encryptedDocument;  // 加密后的文档(Base64)
        private final List<String> indexTokens;  // 索引token列表
        
        public EncryptionResult(String encryptedDocument, List<String> indexTokens) {
            this.encryptedDocument = encryptedDocument;
            this.indexTokens = indexTokens != null ? new ArrayList<>(indexTokens) : new ArrayList<>();
        }
        
        @Override
        public String toString() {
            return String.format("EncryptionResult{encryptedDocument='%s', indexTokens=%d}", 
                encryptedDocument.substring(0, Math.min(50, encryptedDocument.length())), 
                indexTokens.size());
        }
    }
}

3.3 测试用例

可搜索加密测试用例:

/**
 * 测试可搜索加密
 * @param args
 * @author senfel
 * @date 2026/2/12 15:45
 * @return void
 */
public static void main(String[] args) {
    try {
        // 创建可搜索加密服务
        SearchableEncryption se = SearchableEncryption.createDefault();
        
        System.out.println("=== 测试可搜索加密 ===");
        
        // 测试文档
        String document = "这是一份关于Java加密技术的文档。文档介绍了AES加密、RSA加密和可搜索加密等技术。";
        System.out.println("原始文档: " + document);
        
        // 提取关键词
        Set<String> keywords = new HashSet<>(Arrays.asList("Java", "加密", "AES", "RSA", "技术"));
        System.out.println("关键词: " + keywords);
        
        // 加密文档并生成索引
        EncryptionResult result = se.encrypt(document, keywords);
        //加密结果: EncryptionResult{encryptedDocument='VWEdDDd2SgA7UFWDvuenNphVrloKhQGPWveLfiQOqtjtoSBY9b', indexTokens=5}
        //索引token数量: 5
        System.out.println("加密结果: " + result);
        System.out.println("索引token数量: " + result.getIndexTokens().size());
        
        // 解密文档验证
        String decrypted = se.decryptDocument(result.getEncryptedDocument());
        //解密后的文档: 这是一份关于Java加密技术的文档。文档介绍了AES加密、RSA加密和可搜索加密等技术。
        //解密是否成功: true
        System.out.println("解密后的文档: " + decrypted);
        System.out.println("解密是否成功: " + document.equals(decrypted));
        
        System.out.println("\n=== 测试搜索功能 ===");
        
        // 搜索单个关键词
        String searchKeyword = "Java";
        String trapdoor = se.generateTrapdoor(searchKeyword);
        //搜索关键词: Java
        //生成的Trapdoor: PIc6gWBjjkhSYi5sCQq4cf18M3HHOpg494+wwZQZyeA=
        System.out.println("搜索关键词: " + searchKeyword);
        System.out.println("生成的Trapdoor: " + trapdoor);
        
        boolean found = se.search(result.getIndexTokens(), trapdoor);
        //是否找到关键词 'Java': true
        System.out.println("是否找到关键词 '" + searchKeyword + "': " + found);
        
        // 搜索不存在的关键词
        String notFoundKeyword = "Python";
        String notFoundTrapdoor = se.generateTrapdoor(notFoundKeyword);
        boolean notFound = se.search(result.getIndexTokens(), notFoundTrapdoor);
        //是否找到关键词 'Python': false
        System.out.println("是否找到关键词 '" + notFoundKeyword + "': " + notFound);
        
        // 批量搜索
        Set<String> searchKeywords = new HashSet<>(Arrays.asList("Java", "加密", "Python", "AES"));
        Set<String> matched = se.searchMultiple(result.getIndexTokens(), searchKeywords);
        System.out.println("\n批量搜索结果:");
        //搜索关键词: [Java, 加密, Python, AES]
        //匹配的关键词: [Java, 加密, AES]
        System.out.println("搜索关键词: " + searchKeywords);
        System.out.println("匹配的关键词: " + matched);
        
        System.out.println("\n=== 测试自动提取关键词 ===");
        String document2 = "Spring Boot是一个优秀的Java框架,支持RESTful API开发。";
        // 自动提取关键词
        EncryptionResult result2 = se.encrypt(document2, null);
        //文档2: Spring Boot是一个优秀的Java框架,支持RESTful API开发。
        //自动提取的索引token数量: 4
        System.out.println("文档2: " + document2);
        System.out.println("自动提取的索引token数量: " + result2.getIndexTokens().size());
        
        // 搜索
        String trapdoor2 = se.generateTrapdoor("Spring");
        boolean found2 = se.search(result2.getIndexTokens(), trapdoor2);
        //是否找到 'Spring': true
        System.out.println("是否找到 'Spring': " + found2);
        
    } catch (Exception e) {
        System.err.println("测试失败: " + e.getMessage());
        e.printStackTrace();
    }
}

总结

Spring Boot环境下实现加密字段模糊查询需要在安全性和性能之间找到平衡。本文介绍了三种主流方案:
1.格式保留加密:适用于需要保持数据格式的场景,实现复杂但安全性高
2.部分加密与明文索引:平衡安全与性能,适用于大多数业务场景
3.可搜索加密:支持密文直接搜索,性能开销较大但安全性最高
在实际项目中,应根据业务需求和安全要求选择合适的方案。对于大多数企业级应用,部分加密与明文索引是一个不错的选择,它在保证核心数据安全的同时,能够提供较好的查询性能。
未来,随着同态加密技术的成熟和性能提升,可能会成为加密字段模糊查询的终极解决方案。但在当前阶段,上述三种方案仍然是最实用的选择。

以上就是SpringBoot实现加密字段模糊查询的最佳实践的详细内容,更多关于SpringBoot加密字段模糊查询的资料请关注脚本之家其它相关文章!

相关文章

  • 为什么Spring官方推荐的@Transational还能导致生产事故

    为什么Spring官方推荐的@Transational还能导致生产事故

    在Spring中进行事务管理非常简单,只需要在方法上加上注解@Transactional,那么为什么Spring官方推荐的@Transational还能导致生产事故,本文就详细的介绍一下
    2021-11-11
  • 使用Java代码实现RocketMQ的生产与消费消息

    使用Java代码实现RocketMQ的生产与消费消息

    这篇文章介绍一下其他的小组件以及使用Java代码实现生产者对消息的生成,消费者消费消息等知识点,并通过代码示例介绍的非常详细,对大家的学习或工作有一定的帮助,需要的朋友可以参考下
    2024-07-07
  • 详解springBoot启动时找不到或无法加载主类解决办法

    详解springBoot启动时找不到或无法加载主类解决办法

    这篇文章主要介绍了详解springBoot启动时找不到或无法加载主类解决办法,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
    2020-09-09
  • 深入理解 Spring Bean 后处理器@Autowired 等注解的本质(示例demo)

    深入理解 Spring Bean 后处理器@Autowired 等注解的本质(示

    在日常开发中,我们几乎每天都会用到 @Autowired、@Value、@Resource、@PostConstruct 等注解,今天我们通过一个非常简洁的 Demo,一步步揭开 Spring Bean 后处理器的秘密,感兴趣的朋友一起看看吧
    2025-10-10
  • 基于maven的springboot的"过时"用法解析

    基于maven的springboot的"过时"用法解析

    这篇文章主要为大家介绍了基于maven的springboot"过时"用法示例解析,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪
    2023-09-09
  • Java实现经典游戏Flappy Bird的示例代码

    Java实现经典游戏Flappy Bird的示例代码

    Flappy Bird是13年红极一时的小游戏,即摁上键控制鸟的位置穿过管道间的缝隙。本文将用Java语言实现这一经典的游戏,需要的可以参考一下
    2022-02-02
  • Java虚拟机运行时数据区域汇总

    Java虚拟机运行时数据区域汇总

    这篇文章主要给大家介绍了关于Java虚拟机运行时数据区域的相关资料,文中通过示例代码介绍的非常详细,对大家学习或者使用Java具有一定的参考学习价值,需要的朋友们下面来一起学习学习吧
    2019-08-08
  • 基于创建Web项目运行时出错的解决方法(必看篇)

    基于创建Web项目运行时出错的解决方法(必看篇)

    下面小编就为大家带来一篇基于创建Web项目运行时出错的解决方法。小编觉得挺不错的,现在就分享给大家,也给大家做个参考。一起跟随小编过来看看吧
    2017-08-08
  • SpringBoot热部署配置过程

    SpringBoot热部署配置过程

    这篇文章主要介绍了SpringBoot热部署配置过程,具有很好的参考价值,希望对大家有所帮助,如有错误或未考虑完全的地方,望不吝赐教
    2025-03-03
  • 详解Spring中@Component和@Configuration的区别

    详解Spring中@Component和@Configuration的区别

    一直有同学搞不清Spring中@Component和@Configuration这两个注解有什么区别,所以这篇文章小编就给大家简单介绍一下@Component和@Configuration的区别,需要的朋友可以参考下
    2023-07-07

最新评论