译者按:原文铺垫较多,但对于理解作者的心情有一定益处。
基于 PHP 和 普通SQL数据库只要肯琢磨,系统的安全也能大幅提升。
阅读本文,你将了解:
- 针对类似身份证此类敏感信息的存储和查询方案。
- 对 PHP 扩展库Libsodium 有感性的了解
我们总被询问一个相同的问题。这个问题时不时的出现在* open source encryption libraires' bug trackers 。它曾是一个怪异的问题,在my talk at B-Sides Orlando*(名为 针对怪异问题构建防御性解决方案)已有涉及,我们也在一本白皮书中用一章节来描述说明。
这个问题就是:“我们是如何安全加密数据库字段,并且还可以在搜索查询中使用这些字段?“
我们的安全解决方案是相当的简单明了,但在这些提问的团队到发现我们简单的解决方案之间却是充满了危险:糟糕的设计、学院派的搜索工程、误导的市场以及贫乏的威胁建模。
看到这如果你已经急不可耐,可以直接跳到解决方案。
关于可搜索的加密#
让我们从一个简单的场景开始,它可能与政府、医疗应用有一些特殊的关联:
- 你在建立一个新系统,它需要从用户那里收集社会安全号(SSN)。
- 规定和尝试都要求用户的 SSN 应该加密保存。
- 职员需要根据用户的 SSN 来查询对应的账号。
让我们回顾一下针对这个问题的那些显而易见的答案。
不安全的(或欠考虑的)的回答#
非随机的加密##
大多数团队(尤其是没有安全或密码专家的团队)的回答很可能会是如下的情况:
<?php
class InsecureExampleOne
{
protected $db;
protected $key;
public function __construct(\PDO $db, string $key = '')
{
$this->db = $db;
$this->key = $key;
}
public function searchByValue(string $query): array
{
$stmt = $this->db->prepare('SELECT * FROM table WHERE column = ?');
$stmt->execute([
$this->insecureEncryptDoNotUse($query)
]);
return $stmt->fetchAll(\PDO::FETCH_ASSOC);
}
protected function insecureEncryptDoNotUse(string $plaintext): string
{
return \bin2hex(
\openssl_encrypt(
$plaintext,
'aes-128-ecb',
$this->key,
OPENSSL_RAW_DATA | OPENSSL_ZERO_PADDING
)
);
}
}
在上面的片段中,当使用相同的秘钥时,相同原文总会产生相同的密文。但更值得关注的是ECB 模式,它每16个字节块独立加密,这将产生一些极其不幸的后果。
形式上,这些构造函数并不是语义上安全的:如果你加密了一条内容很多的消息,你会看到一些块重复出现在密文中。
为了安全,对于没有解密秘钥的其他人,加密后的信息和随机噪音必须没有明显的区别。非安全的模式包括 ECB 模式和使用静态 (或空)IV的 CBC 模式。
你想要非确定性的加密,这意味着每个消息使用唯一的随机数(nonce,Number Used Once)或者给定秘钥但不会重复初始化向量。
实验性学院派设计##
现在有很多学术研究也在关注这个主题,如 同态(hommorphic)、漏序(order-revealing)、保序(order-preserving)等技术。
这个工作有趣的地方是,现有的设计如果用于生产环境,没有一处是足够安全的。
例如,漏序加密泄露了太多原文的数据。
同态加密的设计通常要将易受攻击部分(实用选择)重新打包作为特点。
- 非填充 RSA 有关乘法是同态的。
- 如果将密文乘以整数,你得到的明文会等于原始消息乘以相同的整数。有很多可能的针对非填充RSA 的攻击,这是为什么在线索中 RSA 使用填充(虽然总是非安全的填充模式)。
- AES 在计数模式下,关于 XOR 也是同态的。
- 这也是为什么在 CTR 模式下重用 nonce 将威胁信息的机密性(通常非 NMR 流式加密也类似)。
如之前博文提到的,当涉及现实世界加密时,没有完整性的机密性相当于没有机密性。如果攻击者获得了数据库的访问权限,修改了密文,研究了应用关于解密的行为,那将会有什么样的后果呢?
在研的这些密码学也许某天能产生创新加密设计,并且不打破密码学几十年的研究成果及协议设计。然而,我们不关注这些,为了解决这个问题,你不用耗费财力、精力在没有必要的复杂的研究原型标准上。
不光彩:解密每行##
我不期望大多数工程师没看到这一串的讽刺而直接看到解决方案。坏主意是,因为你需要安全加密(见下面),你唯一的资源是在数据库中查询每个密文,然后遍历它们,一个接一个解密它们,最后在应用代码中执行你的搜索操作。
如果你完成了这个流程,你将使你的应用面临拒绝服务攻击。你的合法用户响应会变慢。这是犬儒主义的回答,但你可以做到比这更好,下面我们将具体说。
轻松实现安全可搜索加密#
让我们开始一举解决非安全/欠考虑的节所列的问题:所有密文将是一个认证加密组合的结果,如能结合大 Nonce 则更好(Nonce 由安全随机数生成器产生)。
使用认证加密组合,密文是不可确定的(相同消息、秘钥,不同 Nonce,产生不同密文),同时由认证标签保护。一些合适的选项如下:XSalsa20-Poly1305, XChacha20-Poly1305,NORX64-4-1。如果你使用 NaCI(Networking and Cryptography library)或 libsodium,你可以直接使用crypto_secretbox。
于是,我们的密文与随机噪音是很难区分的,可防止选择密文攻击。这就是安全而无趣的加密技术应该的样子。
然而,这引出了新的挑战:我们不能为了匹配密文,而只加密任意消息和数据库查询。幸运的是,有个巧妙的变通方案。
重要:威胁塑造了加密技术的应用
在开始前,确保加密是在实实在在让你的数据更安全。需要着重强调一点,加密存储并不能让 CRUD 应用变得更安全(Create, Read, Update and Delete),这样的应用一般都容易受到 SQL 注入攻击。要解决实际问题(如阻止 SQL 注入)只有一条路可走。
如果加密技术是一种适合执行的安全控制,这就暗示了用于加解密数据的加密秘钥对于数据库软件是不能访问的。大多数情况下,很有意义把应用服务和数据库服务部署在独立的硬件。
我们的威胁模型##
本文后续都假定以下3点成立:
- 你的数据库服务和 Web 服务部署在不同实体物理硬件上(避开VM)。
- 你的数据库服务不知道 Web 服务持有的秘钥。
- 我们保护数据抵抗实弹攻击,而不是危害Web 服务器。危害Web服务器是一场全局的游戏。
- 实弹攻击 vs 线下攻击:你可以简单使用全盘加密,来应对包含硬件被物理偷窃的威胁模型,但这种方法对于来自解密的在线服务器的攻击毫无价值。
威胁模型的其余部分有意的晦涩难懂。只要上面的假设成立,我们的解决方案对于你的威胁模型将是可应用的。
实施加密数据的文字检索#
可能的用例:存储社会安全号码,但对它们进行检索。
为了存储加密后的信息且仍能使用明文在 SELECT 查询中,我们将使用名为盲索引(blind indexing)策略。总体的思路是将明文的带有秘钥的散列(像 HMAC)存储在独立的列。很重要的一点是,盲索引键与加密秘钥无关,且数据库服务不知道它。
对于非常敏感的信息,使用简单的 HMAC 代替,你可以使用秘钥扩展算法(PBKDF2-SHA256,scrypt,Argon2),秘钥作为静态盐使用,以延缓穷举的尝试。我们不担心线下任何的暴力攻击,除非攻击是可以获取秘钥(它不应该存储在数据库)。
因此,如果你的表结构如此(PostgreSQL风格):
CREATE TABLE humans (
humanid BIGSERIAL PRIMARY KEY,
first_name TEXT,
last_name TEXT,
ssn TEXT, /* encrypted */
ssn_bidx TEXT /* blind index */
);
CREATE INDEX ON humans (ssn_bidx);
你可以存储加密值在 humans.ssn。明文 SSN 的盲索引可以存入* human.ssn_bidx*。简单的实现可能会如下:
<?php
/* 这并不是满足生产环境质量的代码。
* 它为了可读性和易于理解做了优化,但未考虑安全性。
*/
function encryptSSN(string $ssn, string $key): string
{
$nonce = random_bytes(24);
$ciphertext = sodium_crypto_secretbox($ssn, $nonce, $key);
return bin2hex($nonce . $ciphertext);
}
function decryptSSN(string $ciphertext, string $key): string
{
$decoded = hex2bin($ciphertext);
$nonce = mb_substr($decoded, 0, 24, '8bit');
$cipher = mb_substr($decoded, 24, null, '8bit');
return sodium_crypto_secretbox_open($cipher, $nonce, $key);
}
function getSSNBlindIndex(string $ssn, string $indexKey): string
{
return bin2hex(
sodium_crypto_pwhash(
32,
$ssn,
$indexKey,
SODIUM_CRYPTO_PWHASH_OPSLIMIT_MODERATE,
SODIUM_CRYPTO_PWHASH_MEMLIMIT_MODERATE
)
);
}
function findHumanBySSN(PDO $db, string $ssn, string $indexKey): array
{
$index = getSSNBlindIndex($ssn, $indexKey);
$stmt = $db->prepare('SELECT * FROM humans WHERE ssn_bidx = ?');
$stmt->execute([$index]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
更综合的概念验证在supplemental material for my B-Sides Orlando 2017 talk。它是基于知识共享 CC0许可发布的,对于大多数人而言等同于公共的。
安全分析及限制##
基于你抽象的威胁模型,这个解决方案遗留了两个在解决前必须注意的问题:
- 它真的使用安全还是像不能保守秘密的人一样会泄露数据?
- 它的使用限制是什么?(这是已经回答的问题)
基于上面的例子,假定你的加密秘钥和盲索引秘钥是分离的,这两个秘钥都存储在 Web 服务器中,数据库服务器就没有方法获得这些秘钥,这样任何危害数据库服务器的攻击者只能知道有一些行记录了社会安全号码,但不知共享的 SSN 是什么。加倍的实体泄露是必要的,目的是为了索引,它反过来允许快速的 SELECT 查询用户提供的值。
此外,如果攻击者能像正常应用的用户那样观察到或改变明文,而且观察到存储在数据库里的忙索引,他们可用利用这个进行选择明文攻击,他们以用户的身份遍历每一个可能的值,并与对应结果的盲索引值关联起来。相比较例如 Argon2的方案,这在 使用HMAC 方案时更加可行。对于高加密或低敏感值(不是 SSN 之类),物理的暴力攻击有利于我们。
对于犯罪分子,更可行的攻击是,用其他行的值来替换,然后正常访问应用程序,后者将返回明文,除非每行都采用不同的秘钥(如hash_hmac('sha256', $rowID, $masterKey, true)能有效减轻,虽然还有更适合的方法)。这里最好的防御是使用 AEAD 模式(传递主秘钥作为附加关联数据),以便于密文与特定的数据库行绑定。(这不能阻止攻击者删除数据,这也是更大的挑战)。
相比较其他方案泄露信息的总量,大部分应用威胁模型都会觉得这个方案是一个可接受的权衡。只要你使用整整加密用于加密,不论 HMAC (针对盲索引非敏感数据)还是密码HASH 算法(盲索引敏感数据),它都很容易得出应用系统的安全性。
然后,它有一个很严格的限制:它只能用于精确匹配。如果两个字符串不同点并没什么意义,但总会产生不同的加密的HASH,此时搜索一个就不会返回另一个。如果你需要做更高级的查询,但仍希望保持你的解密秘钥和明文值不在数据库服务器的范围内,我们就必须更多的创新。
它仍是最佳的方案,当 HMAC/Argon2 可以阻止没有秘钥的攻击者学习存储在数据库中的明文值,它可能泄露真实世界的元数据(如两个无关的人可能共享同一个街道地址)。
实施加密数据的模糊查询#
可能的用例:加密人的合法姓名,支持部分匹配的检索
让我们在前面的章节基础上继续,我们已经建立了一个忙索引,支持精确匹配的方式查询数据库。
下面,不再增加列到已经存在的表,我们将存储额外的索引值到一个 join 表。
CREATE TABLE humans (
humanid BIGSERIAL PRIMARY KEY,
first_name TEXT, /* encrypted */
last_name TEXT, /* encrypted */
ssn TEXT, /* encrypted */
);
CREATE TABLE humans_filters (
filterid BIGSERIAL PRIMARY KEY,
humanid BIGINT REFERENCES humans (humanid),
filter_label TEXT,
filter_value TEXT
);
/* Creates an index on the pair. If your SQL expert overrules this, feel free to omit it. */
CREATE INDEX ON humans_filters (filter_label, filter_value);
这样变更的原因是规范化我们的数据结构。你可以增加列到已有的表中,但它很可能变得混乱。
下一个变更是,针对每种不同的查询需求(每个使用自己的秘钥),我们将独立的不同的盲索引存入不同的列。例如:
- 需要一个大小写敏感的查询并忽略空格?
- 存储盲索引preg_replace('/[^a-z]/', '', strtolower($value))
- 需要查询他们姓的第一个字母?
- 存储盲索引strtolower(mb_substr($lastName, 0, 1, $locale))
- 需要匹配以“某个字母开头,某字母结束”?
- 存储盲索引strtolower($string[0] . $string[-1])
- 需要查询姓的前三个字母和名的第一个字母?
- 你猜到了!建立两一个基于部分数据的盲索引。
每一个索引需要使用不同的秘钥,最大的努力是需要阻止明文子集的盲索引泄露明文真实值给拥有猜词能力的犯罪分子。只为非常必要的商业需求创建索引,日志记录第三方对应用的带有侵略嫌疑的访问。
内存换时间##
到现在为止,所有的设计提议都赞同允许开发者写仔细经过考虑 SELECT 查询,同时最小化加密子程序被调用的次数。整体上,这就像是火车站,大部分人的目标达成了。
然而,有很多情况如果能节省大量的磁盘空间,查询时轻微的性能冲击也是可以接受的。
技巧很简单:截断盲索引到16、32或64位,并按布隆过滤器来处理他们:
- 如果查询触发的盲索引匹配了给定的行,数据可能匹配。
- 你应用代码需要为每个候选行执行解密运算,然后返回实际匹配的结果。
- 如果查询触发的盲索引没有匹配给定的行,那么数据确实没有匹配的。
有时值得把这些值从字符串转化为整型,如果你的数据库服务器最终可以更高效的存储它。
结论#
我希望我已经充分说明了,不仅能建立一个使用安全加密的系统同时允许快速检索(最小的信息泄露,对抗高特权的攻击),而且可能很简单的建立一个系统,而且仅使用现代的加密库及较小的耦合。
如果你有兴趣在你的软件中实现加密的数据库存储,我们很乐意提供你和你的公司咨询服务。如果有兴趣,请联系我们!
作者:Scott Arciszewski
开发总监
15年软件开发、应用安全、系统管理经验,Scott 希望通过解决难题和自动化琐碎的任务,以帮助他人获得快乐的工作和生活平衡。