下面这四个函数解决的是两个容易混淆的不同问题。
存储密码需要既慢又占用大量内存, 这样即使数据库被窃取,也不会直接交出密码。请优先使用 Argon2id,其次是 scrypt, 只有在某个标准明确要求时才使用 PBKDF2。
从一个已经随机、高熵的密钥材料 (例如 X25519 密钥协商的输出)派生密钥需要快速完成: HKDF 才是正确的工具,把一个慢速密码哈希用在本已随机的密钥材料上,只会浪费 CPU 时间, 却得不到任何安全收益。
所有函数分别位于 sgcCrypto_KDF、 sgcCrypto_Scrypt、 sgcCrypto_Argon2 和 sgcCrypto_HKDF 单元中。
PBKDF2
通过多次重复 HMAC 来拉伸一个密码。它消耗的只是 CPU 时间,因此定制硬件(GPU、FPGA) 攻击它的成本要比合法服务器验证一个密码的成本低得多。它是这里三种密码哈希中最弱的一种, 之所以保留,是因为一些标准(WPA2、多种 PKCS 格式)仍按名称明确要求使用它。
function sgcPBKDF2_SHA1(const aPassword, aSalt: TBytes; aIterations, aDKLen: Integer): TBytes;
function sgcPBKDF2_SHA256(const aPassword, aSalt: TBytes; aIterations, aDKLen: Integer): TBytes;
function sgcPBKDF2_SHA384(const aPassword, aSalt: TBytes; aIterations, aDKLen: Integer): TBytes;
function sgcPBKDF2_SHA512(const aPassword, aSalt: TBytes; aIterations, aDKLen: Integer): TBytes;
scrypt
N 是内存/CPU 成本参数:当 r=8 时, 16384 大约需要每次哈希 16 MB 内存,这正是大规模 GPU 攻击变得昂贵而不仅仅是变慢的原因, 因为攻击者必须为内存付出代价,而不只是为计算核心付出代价。
function sgcScrypt(const aPassword, aSalt: TBytes; aN, aR, aP, aDKLen: Integer): TBytes;
var
oHash: TBytes;
begin
oHash := sgcScrypt(TBytes(oPassword), oSalt, 16384, 8, 1, 32);
end;
Argon2
Argon2 赢得了 2015 年密码哈希竞赛,是当前存储密码的首选方案。Argon2id 是应当使用的变体:它把 Argon2d 的数据依赖寻址方式(能有效抵御 GPU 破解) 与 Argon2i 的数据无关首轮处理方式(能抵御可观察内存访问模式的侧信道攻击者)结合在了一起。 RFC 9106 建议交互式登录场景至少使用 19 MiB 内存并迭代 2 次,或者在可以接受额外开销的场景下 使用 64 MiB 内存并迭代 3 次。
function sgcArgon2id(const aPassword, aSalt: TBytes; aMemoryKB, aIterations, aParallelism, aTagLen: Integer): TBytes;
var
oStored, oCandidate: TBytes;
begin
{ at signup }
oStored := sgcArgon2id(TBytes(oPassword), oSalt, 19456, 2, 1, 32);
{ at login }
oCandidate := sgcArgon2id(TBytes(oTypedPassword), oSalt, 19456, 2, 1, 32);
if sgcConstantTimeEquals(oStored, oCandidate) then
{ password accepted };
end;
对于需要特定变体的调用方,Argon2d 和 Argon2i 也可以通过通用入口函数使用:
function sgcArgon2(aKind: TsgcArgon2Kind; const aPassword, aSalt, aSecret, aAssociated: TBytes;
aMemoryKB, aIterations, aParallelism, aTagLen: Integer): TBytes;
HKDF
HKDF 可以把一个优质密钥材料拆分成协议所需数量的多个相互独立的密钥。 aInfo 参数正是保持它们相互独立的关键:同一个密钥材料配合不同的 info 字符串会产生互不相关的密钥, 这正好可以阻止为某一用途(比如加密一条消息)派生出的密钥,同时也能被用于另一用途 (比如认证这条消息)。
function sgcHKDF_SHA256(const aIKM, aSalt, aInfo: TBytes; aLen: Integer): TBytes;
function sgcHKDF_SHA384(const aIKM, aSalt, aInfo: TBytes; aLen: Integer): TBytes;
var
oSharedSecret, oSalt, oClientKey, oServerKey: TBytes;
begin
oClientKey := sgcHKDF_SHA256(oSharedSecret, oSalt, TBytes(AnsiString('client write key')), 32);
oServerKey := sgcHKDF_SHA256(oSharedSecret, oSalt, TBytes(AnsiString('server write key')), 32);
end;
sgcHKDF_SHA256 背后的提取(extract)与扩展(expand)两个步骤也可以单独调用,供需要在不同时机分别执行它们的协议使用:
function sgcHKDF_Extract_SHA256(const aSalt, aIKM: TBytes): TBytes;
function sgcHKDF_Expand_SHA256(const aPRK, aInfo: TBytes; aLen: Integer): TBytes;
KDF1、KDF2 和 ANSI X9.63
来自 ISO 18033-2 和 IEEE 1363a 的三种计数器模式密钥派生函数,同样位于 sgcCrypto_KDF 中。 它们正是 RSA-KEM、ECIES 以及一些较旧的 ANSI 方案按名称指定使用的函数,这也是选用它们的唯一理由。 对于任何新场景,上面的 HKDF 仍是更好的选择,因为它把提取与展开分离开来,而不是直接把密钥材料哈希进输出。
KDF1 和 KDF2 之间只有一处差异,却是一个真实存在的互操作性陷阱:KDF1 的计数器从 0 开始, KDF2 从 1 开始。二者的输出完全不同,每个函数单独看都是正确的,而规范也不总是会说明它指的是哪一个, 因此请查阅规范而不要只看名称。X9.63 就是在计数器之后追加一个共享信息字符串的 KDF2, 这正是 ECIES 和较旧的 ECDH TLS 密码套件所指定的形式。
function sgcKDF1(const aSeed: TBytes; aLen: Integer; aHash: TsgcKDFHash): TBytes;
function sgcKDF2(const aSeed: TBytes; aLen: Integer; aHash: TsgcKDFHash): TBytes;
function sgcKDF_X963(const aSharedSecret, aSharedInfo: TBytes; aLen: Integer; aHash: TsgcKDFHash): TBytes;
var
oSharedSecret, oSharedInfo, oKey: TBytes;
begin
{ ECIES: the ECDH X coordinate plus the shared info the scheme defines }
oKey := sgcKDF_X963(oSharedSecret, oSharedInfo, 32, khSHA256);
end;