ShardingSphere 加密列模糊查询原理与实战

史前动物
发布于 2023-2-27 15:58
浏览
0收藏

一、文章背景

Apache ShardingSphere 提供了数据加密模块,通过对用户输入的 SQL 进行解析,并依据用户提供的加密规则对 SQL 进行改写,从而实现对原文数据进行加密,并将原文数据(可选)及密文数据同时存储到底层数据库。 在用户查询数据时,它仅从数据库中取出密文数据,并对其解密,最终将解密后的原始数据返回给用户。但是由于加密算法是将整串字符串加密,导致模糊搜索功能无法实现。 数据加密后,很多业务都需要使用模糊搜索功能。目前,在刚刚发布的 5.3.0 版本中:

Apache ShardingSphere 为用户提供了默认的模糊查询算法支持加密字段的模糊查询功能,模糊查询算法同样支持热插拔用户可以自定义,并且通过配置即可实现模糊查询。

二、加密场景下『模糊查询』如何实现?

1、加载数据到内存数据库

将所有数据加载到内存数据库中进行解密,这样我们就和查询原文一样了。这个方案虽然可以实现模糊查询,如果数据量小的话可以使用这个方式来做,这样做既简单又实惠,如果数据量大的话那就是灾难。

2、数据库中实现与程序一致的加解密函数

修改模糊查询条件,使用数据库解密函数先解密后再模糊查找,这样做的优点是实现成本低,开发使用成本低,只需要将以往的模糊查找稍微修改一下就可以实现。但是数据库中密文和加密函数存储在一起,无法应对数据库账号泄漏场景。

原生 SQL: select * from user where name like "%xxx%" 
实现解密函数之后: ѕеlесt * frоm uѕеr whеrе dесоdе(namе) lіkе "%ххх%"

3、脱敏存储

对密文数据进行脱敏后存储到模糊查询列,该方案会丢失太多精度。

例如:手机号为 13012345678 通过脱敏算法得到 130****5678

4、分词组合加密存储

对密文数据进行分词组合,将分词组合的结果集分别进行加密。对字符进行固定长度的分组,将一个字段拆分为多个,比如说根据 4 位英文字符,2 个中文字符为一个检索条件,举个例子:

ningyu1 使用 4 个字符为一组的加密方式:第一组 ning,第二组 ingy,第三组 ngyu,第四组 gyu1 以此类推,全部加密之后存储到模糊查询列。如果需要检索所有包含检索条件 4 个字符的数据,比如:ingy

加密字符后,通过 key like“%partial%” 完成查询。


缺点如下 :

存储成本增加

自由分组会增加数据量,加密后长度会增长。

模糊查询长度有限制

由于安全问题,自由组合长度不能太低,不然可以采取彩虹表破解。对模糊查询的字符长度是有要求的。以我上面举的例子,模糊查询字符原文长度,必须大于等于 4 个英文/数字,或者 2 个汉字才能搜索到结果。

5、5.3.0 提供的默认模糊查询算法

   (单字符摘要算法)

上述方案都能使用,但是有没有更好的方案。我们发现单字符加密存储是性能和搜索都能兼顾的,但是不符合安全要求,那应该怎么办?受到脱敏算法和密码散列函数的启发,我们发现丢失数据和单向函数这个思路可以借鉴。密码散列函数应该有的四个主要特性:

ShardingSphere 加密列模糊查询原理与实战-鸿蒙开发者社区

安全性  因为有单向函数,所以是不可以推算原始消息的。因为我们想模糊搜索精确度提升,所以想单字符加密,但是又会被彩虹表破解。所以我们借鉴单向函数(保证每个字符加密之后的字符一致),然后增加碰撞(保证每个字符串逆向结果为 1:N),这样就大大加强了安全性。

三、模糊查询算法

Apache ShardingSphere 内部采用上述单字符摘要算法实现了一个通用的模糊查询算法 org.apache.shardingsphere.encrypt.algorithm.like.CharDigestLikeEncryptAlgorithm。

public final class CharDigestLikeEncryptAlgorithm implements LikeEncryptAlgorithm<Object, String> {

    private static final String DELTA = "delta";

    private static final String MASK = "mask";

    private static final String START = "start";

    private static final String DICT = "dict";

    private static final int DEFAULT_DELTA = 1;

    private static final int DEFAULT_MASK = 0b1111_0111_1101;

    private static final int DEFAULT_START = 0x4e00;

    private static final int MAX_NUMERIC_LETTER_CHAR = 255;

    @Getter
    private Properties props;

    private int delta;

    private int mask;

    private int start;

    private Map<Character, Integer> charIndexes;

    @Override
    public void init(final Properties props){
        this.props = props;
        delta = createDelta(props);
        mask = createMask(props);
        start = createStart(props);
        charIndexes = createCharIndexes(props);
    }

    private int createDelta(final Properties props){
        if (props.containsKey(DELTA)) {
            String delta = props.getProperty(DELTA);
            try {
                return Integer.parseInt(delta);
            } catch (NumberFormatException ex) {
                throw new EncryptAlgorithmInitializationException("CHAR_DIGEST_LIKE", "delta can only be a decimal number");
            }
        }
        return DEFAULT_DELTA;
    }

    private int createMask(final Properties props){
        if (props.containsKey(MASK)) {
            String mask = props.getProperty(MASK);
            try {
                return Integer.parseInt(mask);
            } catch (NumberFormatException ex) {
                throw new EncryptAlgorithmInitializationException("CHAR_DIGEST_LIKE", "mask can only be a decimal number");
            }
        }
        return DEFAULT_MASK;
    }

    private int createStart(final Properties props){
        if (props.containsKey(START)) {
            String start = props.getProperty(START);
            try {
                return Integer.parseInt(start);
            } catch (NumberFormatException ex) {
                throw new EncryptAlgorithmInitializationException("CHAR_DIGEST_LIKE", "start can only be a decimal number");
            }
        }
        return DEFAULT_START;
    }

    private Map<Character, Integer> createCharIndexes(final Properties props){
        String dictContent = props.containsKey(DICT) && !Strings.isNullOrEmpty(props.getProperty(DICT)) ? props.getProperty(DICT) : initDefaultDict();
        Map<Character, Integer> result = new HashMap<>(dictContent.length(), 1);
        for (int index = 0; index < dictContent.length(); index++) {
            result.put(dictContent.charAt(index), index);
        }
        return result;
    }

    @SneakyThrows
    private String initDefaultDict(){
        InputStream inputStream = CharDigestLikeEncryptAlgorithm.class.getClassLoader().getResourceAsStream("algorithm/like/common_chinese_character.dict");
        LineProcessor<String> lineProcessor = new LineProcessor<String>() {

            private final StringBuilder builder = new StringBuilder();

            @Override
            public boolean processLine(final String line){
                if (line.startsWith("#") || 0 == line.length()) {
                    return true;
                } else {
                    builder.append(line);
                    return false;
                }
            }

            @Override
            public String getResult(){
                return builder.toString();
            }
        };
        return CharStreams.readLines(new InputStreamReader(inputStream, Charsets.UTF_8), lineProcessor);
    }

    @Override
    public String encrypt(final Object plainValue, final EncryptContext encryptContext){
        return null == plainValue ? null : digest(String.valueOf(plainValue));
    }

    private String digest(final String plainValue){
        StringBuilder result = new StringBuilder(plainValue.length());
        for (char each : plainValue.toCharArray()) {
            char maskedChar = getMaskedChar(each);
            if ('%' == maskedChar) {
                result.append(each);
            } else {
                result.append(maskedChar);
            }
        }
        return result.toString();
    }

    private char getMaskedChar(final char originalChar){
        if ('%' == originalChar) {
            return originalChar;
        }
        if (originalChar <= MAX_NUMERIC_LETTER_CHAR) {
            return (char) ((originalChar + delta) & mask);
        }
        if (charIndexes.containsKey(originalChar)) {
            return (char) (((charIndexes.get(originalChar) + delta) & mask) + start);
        }
        return (char) (((originalChar + delta) & mask) + start);
    }

    @Override
    public String getType(){
        return "CHAR_DIGEST_LIKE";
    }
}

· 先定义二进制 mask 码用来丢失精度

   0b1111_0111_1101 (mask); 

· 将常见汉字乱序后,按照顺序存为 map 字典; 

· 数字、英文、拉丁文可取单个字符串的 Unicode 码; 

· 属于字典的文字获取 index; 

· 其他字符取单个字符串的 Unicode 码; 

· 然后将上述不同类型获取的数字进行加 1 (delta) 防止任何原文出现在数据库; 

· 然后将偏移后的 Unicode 码转换成二进制和 mask 进行与运算,进行 2 位的数字丢失; 

· 数字、英文、拉丁文丢失精度之后直接输出; 

· 剩余字符失精度后,转成十进加上常见字起始码

   (start) 输出。

四、模糊算法演进

第一版算法

单纯采取常用字的 Unicode 与 mask 码进行与运算:

Mask:0b11111111111001111101
原字符:0b1000101110101111讯
加密后:0b1000101000101101設

假设我们知道秘钥和加密算法的情况下,反推出来的原字符串为:

1.0b1000101100101101 謭
2.0b1000101100101111 謯
3.0b1000101110101101 训
4.0b1000101110101111 讯
5.0b1000101010101101 読
6.0b1000101010101111 誯
7.0b1000101000101111 訯
8.0b1000101000101101 設

我们发现每个字符串会根据丢失的位数,反推出来的结果为 2^n。因为常见汉字的 Unicode 码在十进制时间隔都很大,可以发现反推出来的汉字基本都不是常用字,反推出原字的几率比较大。

ShardingSphere 加密列模糊查询原理与实战-鸿蒙开发者社区

第二版算法

由于常见汉字 Unicode 码间隔没有规律,所以我们准备把汉字 Unicode 码的后几位留下,转成十进作为 index 去常见汉字中取词。这样就解决在知道算法的情况下,反解出现非常见字的问题,干扰项就不再容易排除了。

汉字 Unicode 留下后几位,涉及到一个模糊精确度和反解密复杂度的一个关系,模糊精确度高了相应的解密难度就降低了。下面我们看一下常见汉字在我们算法下的碰撞程度:

  1. mask=0b0011_1111_1111 时:

ShardingSphere 加密列模糊查询原理与实战-鸿蒙开发者社区

  1. mask=0b0001_1111_1111 时:

ShardingSphere 加密列模糊查询原理与实战-鸿蒙开发者社区

汉字尾数留 10 位和留 9 位。10 位的查询精度会更高,因为 10 位的碰撞会小很多,但是 1:1 的字在知道算法和秘钥的的情况下是可以反推出原文的。9 位的查询精度稍弱,因为 9 位的碰撞相对来说大一点,但是 1:1 的汉字较少。仔细观察会发现,不管是留 10 位还是留 9 位虽然我们改变了碰撞,但是由于汉字原本 Unicode 码没有规律,导致分布非常不均衡,不能控制整体的一个碰撞概率。

第三版算法

基于第二版发现分布不均衡的问题,我们把常用字乱序作为字典表。

  1. 加密文字先在乱序字典表中查找 index,我们用取到的 index 下标代替没有规则 Unicode 码,如果非常用字还是使用 Unicode 码。(让参与计算的 code 尽量分布均衡)
  2. 第二步是和 mask 进行与运算丢失 2 位精度增加碰撞。

下面我们看一下,常见的汉字在算法下的碰撞程度:

  1. mask=0b1111_1011_1101 时:

ShardingSphere 加密列模糊查询原理与实战-鸿蒙开发者社区

  1. mask=0b0111_1011_1101 时:

ShardingSphere 加密列模糊查询原理与实战-鸿蒙开发者社区

Mask 选择留 11 位的时候,可以看到碰撞分布相对来说集中在 1:4 上,Mask 选择留 10 位的时候,可以看到碰撞分布相对来说集中在1:8上,此时我们只需要调整丢失精度的个数就能控制碰撞是 1:2 还是 1:4 还是 1:8 了。

Mask 选择 1,且知道算法和秘钥的情况下会有一个 1:1 的汉字,因为此时我们计算的是常见字的碰撞程度,如果加上汉字 16 位二进制前面丢失的 4 位,情况就变成了 2^5=32 种情况。由于我们加密是整段文字,即使反推出个别字对于整体安全性影响不大,不会造成大批量数据泄密,同时前提是要知道算法、秘钥、delta 和字典才具备反推的可能,仅从数据库中的数据,无反推可能性。

五、模糊查询的使用

模糊查询需要在加密配置中配置 encryptors (加密算法配置)、likeQueryColumn (模糊查询列名称)、likeQueryEncryptorName (模糊查询列加密算法名称)。参考配置如下,分片算法、数据源等配置请自行添加。

dataSources:
  ds_0:
    dataSourceClassName: com.zaxxer.hikari.HikariDataSource
    driverClassName: com.mysql.jdbc.Driver
    jdbcUrl: jdbc:mysql://127.0.0.1:3306/test?allowPublicKeyRetrieval=true
    username: root
    password: root

rules:
- !ENCRYPT
  encryptors:
    like_encryptor:
      type: CHAR_DIGEST_LIKE
    aes_encryptor:
      type: AES
      props:
        aes-key-value: 123456abc
  tables:
    user:
      columns:
        name:
          cipherColumn: name
          encryptorName: aes_encryptor
          assistedQueryColumn: name_ext
          assistedQueryEncryptorName: aes_encryptor
          likeQueryColumn: name_like
          likeQueryEncryptorName: like_encryptor
        phone:
          cipherColumn: phone
          encryptorName: aes_encryptor
          likeQueryColumn: phone_like
          likeQueryEncryptorName: like_encryptor
  queryWithCipherColumn: true

props:
  sql-show: true

Insert 示例:

Logic SQL: insert into user ( id, name, phone, sex) values ( 1, '熊高祥', '13012345678', '男')
Actual SQL: ds_0 ::: insert into user ( id, name, name_ext, name_like, phone, phone_like, sex) values (1, 'gyVPLyhIzDIZaWDwTl3n4g==', 'gyVPLyhIzDIZaWDwTl3n4g==', '佹堝偀', 'qEmE7xRzW0d7EotlOAt6ww==', '04101454589', '男')

Update 示例:

Logic SQL: update user set name = '熊高祥123', sex = '男1' where sex ='男' and phone like '130%'
Actual SQL: ds_0 ::: update user set name = 'K22HjufsPPy4rrf4PD046A==', name_ext = 'K22HjufsPPy4rrf4PD046A==', name_like = '佹堝偀014', sex = '男1' where sex ='男' and phone_like like '041%'

Select 示例:

Logic SQL: select * from user where (id = 1 or phone = '13012345678') and name like '熊%'
Actual SQL: ds_0 ::: select `user`.`id`, `user`.`name` AS `name`, `user`.`sex`, `user`.`phone` AS `phone`, `user`.`create_time` from user where (id = 1 or phone = 'qEmE7xRzW0d7EotlOAt6ww==') and name_like like '佹%'

Select 联表子查询示例:

Logic SQL: select * from user LEFT JOIN user_ext on user.id=user_ext.id where user.id in (select id from user where sex = '男' and name like '熊%')
Actual SQL: ds_0 ::: select `user`.`id`, `user`.`name` AS `name`, `user`.`sex`, `user`.`phone` AS `phone`, `user`.`create_time`, `user_ext`.`id`, `user_ext`.`address` from user LEFT JOIN user_ext on user.id=user_ext.id where user.id in (select id from user where sex = '男' and name_like like '佹%')

Delete 示例:

Logic SQL: delete from user where sex = '男' and name like '熊%'
Actual SQL: ds_0 ::: delete from user where sex = '男' and name_like like '佹%'

上述示例演示了模糊查询列在不同 SQL 语法中是如何改写 SQL 支持模糊查询的。本文详细介绍了模糊查询的基本实现原理以及具体的使用示例。

相信通过本文,读者朋友们对模糊查询都有了一些基本的了解。大家可以根据自己的需求使用 模糊查询 或者 自定义算法。 如果是通用算法,也欢迎大家向社区提交。在使用过程中遇到任何问题或者有任何想法,都欢迎来社区反馈!

作者简介

熊高祥,科大讯飞工程师

ShardingSphere Contributor

主要负责数据『加密 & 脱敏』研发工作

文章转载自公众号:  ShardingSphere官微

分类
已于2023-2-27 15:58:41修改
收藏
回复
举报
回复
    相关推荐