近几年我朝对隐私保护、数据安全越来越重视,一些业务不得不开始搞数据加密了,商业数据库有比较好的数据库内加密支持,尤其以 SQL Server 支持最完善,但开源数据库就没这么高级了,于是需要折腾应用层加密,这就是个非常头大的事情了,有两个难点:

  1. 如何【方便】的在服务与服务之间走明文(可以有传输层加密),在服务与数据库之间走密文(指应用层协议加密,存储密文到数据库,并非仅仅 TLS 传输层加密),尤其是后者,因为数据库访问框架众多,怎么拦截敏感字段的读写,确保不漏掉加密、不错误加密,是很头大的,没见到业界有简单的解决办法。 
  2. 对加密的字段,比如姓名、地址,如何进行 LIKE 查询,网上有一些做法是对明文分词然后加密存放,这个做法缺点很明显,密文非常长,很浪费存储,查询也低效,而且如果采用确定性加密,那么很容易根据分词结果建立明文和密文的映射词典,就可以无需密钥反解出来明文。

国庆期间研究 MyBatis 和 jOOQ,忽然受到启发,可以很容易的解决第一个问题:

  1. 为数据库里每一个敏感的列定义单独的 EncryptedXxx 类型(可用 Java annotation processor 自动根据 entity class 的 field annotation 生成),这个类型里编码了列名、 明文和密文三个信息。
  2. 给 Jackson、Gson 编写类型转换插件,可以让它们在 Encrypted 与明文之间自动转换。
  3. 给 MyBatis、jOOQ 等数据库访问框架编写类型转换插件,可以让它们在 Encrypted 与密文之间自动转换。
  4. 在业务代码里 Entity class 的敏感字段从 String 改成 EncryptedXxx 类型,总是使用 Encrypted.get() 和 Encrypted.set() 读写明文,由于不再是原始的明文 String类型,所以哪些代码需要改动,可以根据编译器类型检查错误自动发现。

这个做法是通用的,适用于所有主流编程语言,所有数据序列化库,也适用于所有带 ORM 能力的数据库访问库,所需要编写的类型转换插件一般不到一百行代码,很容易实施。

第二个问题也脑洞大开,想到了一个很好的办法:对每一个汉字,取其 UTF-8 编码的末尾字节(只有 6 bits 有效数据),拼在一起存放到数据库里,将原来的明文 LIKE 改成这个编码后字符串的 LIKE,由于是一个字符映射到一个字符,长度不变,所以可以照样做 LIKE 运算,甚至做正则表达式匹配,匹配结果需要在应用层解密再进一步过滤,因为这个 hash 算法的碰撞概率很低,所以从数据库 LIKE 查询出来的假结果不会很多,而由于本质是 hash 算法,所以也没法从编码字符串反推明文字符串。这个方案的存储开销也非常小,输入有多少字符,输出就有多少字节,不必加密成很长的密文。

下面是这个编码的示例代码:

    private static final char[] toBase64URL = {
        'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M',
        'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z',
        'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm',
        'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z',
        '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '-', '_'
    };

    public static String transform(String s) {
        StringBuilder b = new StringBuilder(s.length());

        s.codePoints().forEach(c -> b.append(toBase64URL[c & (c <= '9' ? 3 : c < 256 ? 7 : 63)]));

        return b.toString();
    }

这里用了 UTF-8 编码末尾字节的 6bit 有效数据,也可以用其它 hash 算法,取结果的低 6 位。经实测真实姓名以及百家姓的姓氏,发现这个 UTF-8 编码的 hash 算法已经足够好了:碰撞概率很低,但又足够混淆,不能从编码确定明文。