找回密码
立即注册
搜索
发回帖 发新帖

6467

积分

1

好友

808

主题
发表于 2 小时前 | 查看: 1| 回复: 0

数据库里有个手机号字段,安全要求一上来,改成 AES 加密存储。

改完查询详情、修改资料都没问题,直到产品丢过来一个需求:

手机号支持模糊搜索。

原来的 SQL 很简单:

select id, user_name, mobile
from customer
where mobile like '%1388%';

加密之后再这么查,直接歇菜。

表里的数据已经变成这样:

13888881234
        ↓ AES
T6qvX8c7QH6nYwY7mY...

你搜 1388,数据库面对的是一坨完全看不出规律的密文。

我第一次碰到这种需求时也想过一个很自然的办法:

String cipher = encrypt("1388");

select *
from customer
where mobile_cipher like '%cipher%';

但这条路根本走不通。

AES 这种正常的加密算法有个很重要的特征:明文只改一点点,整个密文都可能完全不同。

比如:

13888881234 -> lPp8hM...
13888881235 -> xR3zQ9...

两条明文前 10 位都一样,密文之间却不存在什么“公共前缀”。

所以有一句话得先记住:

密文本身基本没法直接做 LIKE。

别在 SQL 上折腾。

where aes_decrypt(mobile_cipher, 'key') like '%1388%'

这种 SQL 虽然理论上能跑,我一般不会让它进生产。

原因也很直接。

数据库得先把数据一条条解密,再做匹配,普通索引基本利用不上。数据量一上来,本来一个简单查询,很容易变成全表扫描加批量解密。

更麻烦的是,你把密钥、解密函数这些东西塞进数据库,密钥管理也跟着变复杂了。

所以这个问题真正难的地方不是“Java 怎么解密”,而是:

既不能直接查密文,又不想查询时把整张表解密一遍,那到底查什么?

我线上更愿意做一层额外的“检索索引”。

原始手机号还是正常加密:

mobile_cipher = AES(mobile)

但另外生成一套只用于搜索的数据。

比如手机号:

13888881234

假设业务只允许最少输入 4 位,我们可以拆成:

1388
3888
8888
8881
8812
8123
1234

不过这些字符串也不能直接明文落库。

否则你数据库里虽然把手机号加密了,旁边却躺着:

1388
3888
8888
...

这个加密基本等于白干。

我的做法一般是对这些检索片段再做一次带密钥的 HMAC:

1388 -> HMAC -> a8f31...
3888 -> HMAC -> c92d7...
8888 -> HMAC -> 71b0a...

最后数据库大概长这样。

用户表:

create table customer (
    id bigint primary key,
    user_name varchar(64) not null,
    mobile_cipher varchar(256) not null
);

检索表:

create table customer_mobile_idx (
    customer_id bigint not null,
    token char(64) not null,
    primary key (customer_id, token),
    index idx_mobile_token(token)
);

保存用户时,手机号只保存密文。

Java 里我会把“加密”和“搜索索引”彻底分开,不混成一个工具方法。

public void saveCustomer(CustomerCmd cmd) {
    long customerId = customerIdGenerator.nextId();

    String mobile = normalizeMobile(cmd.mobile());
    String cipherText = mobileCipher.encrypt(mobile);

    customerRepository.insert(
            customerId,
            cmd.userName(),
            cipherText
    );

    buildSearchTokens(mobile).forEach(token ->
            mobileIndexRepository.insert(customerId, token)
    );
}

生成检索 token 的代码也不需要搞得很花。

private Set<String> buildSearchTokens(String mobile) {
    int width = 4;
    Set<String> tokens = new HashSet<>();

    for (int start = 0; start + width <= mobile.length(); start++) {
        String piece = mobile.substring(start, start + width);
        tokens.add(searchSigner.sign(piece));
    }

    return tokens;
}

searchSigner 我不会直接用普通 SHA-256。

像手机号这种数据取值空间并不大,如果只是:

sha256("1388")

攻击者拿到数据库以后完全可以提前把 0000 ~ 9999 全算一遍,然后反查。

所以这里至少用 HMAC,并且搜索密钥不能放数据库里。

public final class SearchSigner {

    private final SecretKeySpec key;

    public SearchSigner(byte[] secret) {
        this.key = new SecretKeySpec(secret, "HmacSHA256");
    }

    public String sign(String text) {
        try {
            Mac mac = Mac.getInstance("HmacSHA256");
            mac.init(key);

            byte[] digest = mac.doFinal(
                    text.getBytes(StandardCharsets.UTF_8)
            );

            return HexFormat.of().formatHex(digest);
        } catch (GeneralSecurityException e) {
            throw new IllegalStateException("build search token failed", e);
        }
    }
}

现在用户搜索:

1388

Java 不需要解密数据库里的所有手机号,只要先计算:

String token = searchSigner.sign("1388");

然后:

select c.id, c.user_name, c.mobile_cipher
from customer c
join customer_mobile_idx i
on i.customer_id = c.id
where i.token = ?;

token 上有索引,这个查询和:

where mobile_cipher like '%1388%'

已经完全不是一个路子了。

查出候选记录以后,再在 Java 里解密。

public List<CustomerView> searchByMobile(String keyword) {
    String input = normalizeMobile(keyword);

    if (input.length() < 4) {
        throw new IllegalArgumentException("手机号至少输入4位");
    }

    String firstToken = searchSigner.sign(
            input.substring(0, 4)
    );

    List<CustomerEntity> candidates =
            customerRepository.findByMobileToken(firstToken);

    return candidates.stream()
            .map(entity -> {
                String mobile = mobileCipher.decrypt(entity.mobileCipher());

                return new CustomerView(
                        entity.id(),
                        entity.userName(),
                        mobile
                );
            })
            .filter(view -> view.mobile().contains(input))
            .limit(100)
            .toList();
}

注意最后这个:

mobile.contains(input)

我一般不会省。

因为旁边那张索引表负责的是快速缩小候选范围,真正判断匹不匹配,还是应该拿解密后的原文再校验一次。

尤其需求从“搜索任意连续 4 位”变成“任意长度模糊搜索”以后,这一步很重要。

比如用户输入:

138888

可以切成多个 4 位片段:

1388
3888
8888

三个 token 都命中的用户,才进入候选集。

SQL 也可以写成:

select customer_id
from customer_mobile_idx
where token in (?, ?, ?)
group by customer_id
having count(distinct token) = 3;

然后再解密候选手机号做一次:

mobile.contains("138888")

这么干,我比较放心。

因为数据库负责粗筛,Java 负责最终确认。

还有一种方案经常有人提:

确定性加密。

也就是同一手机号:

13888881234

不管加密多少次,永远得到相同密文。

这样:

where mobile_cipher = ?

确实能用了。

但注意,是:

=

不是:

like

它只能解决精确查询。

你搜完整手机号:

13888881234

可以先加密,再查询密文。

但你只输入:

8888

还是没法从完整密文里匹配出来。

所以精确查询和模糊查询最好别混为一谈。

我见过还有一种做法更直接:把手机号加密以后,再额外存:

mobile_last4
mobile_prefix3

例如:

mobile_cipher = AES(13888881234)
mobile_prefix3 = 138
mobile_last4   = 1234

如果业务需求永远只是:

手机号后四位查询

这种办法反而很实用。

没必要为了四位尾号查询搞一套复杂索引。

但安全要求高一点,这两个字段也不要直接裸存,可以继续做 HMAC:

mobile_last4_token = HMAC(1234)

然后直接等值查。

我做这种需求时一般先问产品一句:

你这个“模糊查询”,到底模糊到什么程度?

这句话非常重要。

有的需求嘴上说模糊搜索,真正用起来其实只有:

完整手机号搜索
手机号后四位搜索
身份证后六位搜索
邮箱完整匹配

这种直接做几个固定 HMAC 索引就行。

如果真要求:

任意位置
任意长度
中文姓名
手机号
邮箱
地址

全部支持模糊匹配,那我就不会继续往 MySQL 里硬塞了。

数据量大之后,检索本来就是搜索系统干的活。

可以设计成:

MySQL
  └── 保存业务数据 + 加密原文

搜索索引
  └── 保存经过脱敏、分词或者专门处理后的检索字段

但这时要注意另一件事:

搜索能力越强,泄露的信息一般也越多。

这一点很容易被忽略。

你给一个加密字段建立:

前缀索引
后缀索引
2-gram
3-gram
4-gram

检索是爽了,但攻击者拿到索引以后,能分析出的信息也越来越多。

哪怕用了 HMAC,也不代表完全没有信息泄露。

比如同一个 token 重复出现多少次,仍然可能暴露频率特征。

所以这件事没有一个“既能 AES 强加密,又能随便 LIKE,而且性能还跟明文一样”的魔法方案。

至少常规业务系统里,我不会这么承诺。

真正落地时,我一般按查询需求拆:

完整值查询
    -> HMAC / 确定性方案,等值查询

固定前后缀
    -> 单独建立 HMAC 检索字段

有限长度的包含查询
    -> n-gram + HMAC 检索索引 + 原文二次校验

复杂全文检索
    -> 独立搜索系统,重新评估数据暴露范围

完全没有查询需求
    -> 只保存随机化加密后的密文

尤其别为了方便写出这种东西:

where decrypt(mobile_cipher) like '%8888%'

测试库几千条数据的时候,你会觉得挺好。

等表里数据多起来,再有人从后台输入一个 88,数据库开始一行一行解密,那场面就不太好看了。

加密字段要支持查询,通常都得接受一个现实:

真正被查询的,往往不是那份密文本身,而是你专门为查询另外设计的一份索引。

至于这份索引到底建到什么程度,不是 Java 技术问题。

得先看你究竟需要搜什么,以及这些查询能力值不值得拿额外的信息暴露去换。




上一篇:Spring AI Alibaba停更真相:Java AI框架分层后怎么选?
下一篇:30+跟小6岁Leader共事不会称呼?技术资产沉淀才是关键
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-10-8 03:12 , Processed in 0.112508 second(s), 39 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表