引言
如今,到处都有两因素身份验证。 多亏了她,要窃取帐户,仅提供密码是不够的。 并且,尽管它的存在不能保证您的帐户不会被删除,但是仍然需要进行更复杂的多层攻击。 如您所知,这个世界上的事物越复杂,它就越不可能起作用。
我敢肯定,阅读本文的每个人在生活中至少都会使用过两次身份验证(以下称为2FA,一个痛苦的长短语)。 今天,我邀请您了解这项技术的工作原理,该技术每天保护无数帐户。
但是对于初学者来说,您可以看一下我们今天要做的演示 。
基础知识
值得一提的一次性密码的第一件事是它们有两种类型: HOTP和TOTP 。 即, 基于HMAC的一次性密码和基于时间的OTP 。 TOTP只是HOTP的一个附加组件,因此让我们首先讨论一个更简单的算法。
HOTP由RFC4226规范描述。 它很小,只有35页,包含您需要的所有内容:正式说明,示例实现和测试数据。 让我们看一下基本概念。
首先,什么是HMAC ? HMAC代表基于哈希的消息身份验证代码 ,或俄语中的“使用哈希功能的消息身份验证代码”。 MAC是一种用于验证消息发件人的机制。 MAC算法使用仅发送方和接收方已知的密钥生成MAC标签 。 收到消息后,您可以自己生成MAC标签并比较两个标签。 如果它们重合-一切都井井有条,则通信过程不会受到干扰。 另外,您可以以相同的方式检查消息在传输过程中是否已损坏。 当然,将干扰与损坏区分开是行不通的,但是信息损坏的事实就足够了。

什么是哈希? 哈希是对消息应用哈希函数的结果。 哈希函数将数据取为固定长度的字符串。 一个很好的例子是众所周知的MD5功能,该功能已广泛用于验证文件完整性。
MAC本身不是特定的算法,而只是一个通用术语。 反过来, HMAC已经是一个具体的实现。 更具体地说,是HMAC- X ,其中X是加密哈希函数之一。 HMAC接受两个参数:密钥和消息,以某种方式将它们混合,两次应用所选的哈希函数,并返回MAC标签。
如果您目前正在考虑一次密码与这一切有什么关系,请放心,我们已接近要点。
根据规范,HOTP是基于两个值计算的:
- K是客户端和服务器知道的秘密密钥 。 它的长度至少应为128位,最好为160位,并在配置2FA时创建。
- C是柜台 。
计数器是客户端和服务器之间同步的8字节值。 生成新密码时,它会更新。 在HOTP方案中,每次您生成新密码时,客户端计数器都会增加。 在服务器端,每次密码成功通过验证时。 由于可以生成而不使用密码,因此服务器允许计数器值在已建立的窗口中提前运行。 但是,如果您过多使用HOTP方案中的密码生成器,则必须再次进行同步。
这样啊 您可能已经注意到,HMAC还接受两个参数。 RFC4226定义了HOTP生成功能,如下所示:
HOTP(K,C) = Truncate(HMAC-SHA-1(K,C))
可以预期的是, K被用作密钥。 计数器又用作消息。 在HMAC函数生成MAC标签之后,神秘的Truncate
函数会提取出我们已经熟悉的一次性密码,您可以在生成器应用程序或令牌中看到该密码。
让我们开始编写代码,然后处理其余部分。
实施计划
要获得一次性密码,我们需要执行以下步骤。

- 根据K和C参数生成HMAC-SHA1哈希。 这将是一个20字节的字符串。
- 以特定方式从此字符串中提取4个字节。
- 将提取的值转换为数字,然后将其除以10 ^ n,其中n =一次性密码中的位数(通常为n = 6)。 最后,采用该部门的其余部分。 这将是我们的密码。
听起来不太难,对吧? 让我们从哈希生成开始。
生成HMAC-SHA1
这也许是上面列出的最简单的步骤。 我们不会尝试自己重新创建算法(我们永远不需要尝试自行实现加密技术)。 相反,我们将使用Web Crypto API 。 小问题是该规范API仅在安全上下文(HTTPS)下可用。 对我们来说,这充满了一个事实,那就是如果不在开发服务器上设置HTTPS,就无法使用它。 在这里可以找到一些历史和关于如何做出正确决定的讨论。
幸运的是,在Firefox中,您可以在不安全的环境中使用Web Crypto,而不必重新发明轮子或拖拽第三方库。 因此,出于开发演示的目的,我建议使用FF。
Crypto API本身在window.crypto.subtle
定义。 如果您对这个名称感到惊讶,请参考一下规范:
该API称为SubtleCrypto
,反映了许多算法都有特定的使用要求这一事实。 只有满足这些要求,它们才能保持其耐久性。
让我们来看看我们需要的方法。 注意:这里提到的所有方法都是异步的,并返回Promise
。
首先,我们需要importKey
方法,因为我们将使用私钥,而不是在浏览器中生成私钥。 importKey
接受5个参数:
importKey( format, keyData, algorithm, extractable, usages );
在我们的情况下:
format
将为'raw'
,即 我们将提供密钥作为ArrayBuffer
字节数组。keyData
是相同的ArrayBuffer。 很快我们将讨论如何生成它。- 根据规范,
algorithm
将为HMAC-SHA1
。 此参数必须与HmacImportParams的格式匹配。 extractable
为false,因为我们没有计划导出密钥- 最后,在所有
usages
我们只需要'sign'
。
我们的密钥将是一个长随机字符串。 在现实世界中,这可能是一个字节序列,可能是无法打印的,但是为了方便起见,在本文中,我们将考虑一个字符串。 要将其转换为ArrayBuffer
我们将使用TextEncoder
接口。 有了它,就可以用两行代码来准备密钥:
const encoder = new TextEncoder('utf-8'); const secretBytes = encoder.encode(secret);
现在,我们将它们放在一起:
const Crypto = window.crypto.subtle; const encoder = new TextEncoder('utf-8'); const secretBytes = encoder.encode(secret); const key = await Crypto.importKey( 'raw', secretBytes, { name: 'HMAC', hash: { name: 'SHA-1' } }, false, ['sign'] );
太好了! 准备了密码学。 现在,我们将处理柜台并最终在消息上签名。
根据规范,我们的计数器应为8个字节长。 我们将再次使用它,就像ArrayBuffer
。 为了将其转换为这种形式,我们将使用JS中通常使用的技巧将零保存在数字的高位数字中。 之后,我们将使用DataView
将每个字节放入ArrayBuffer
。 请记住,根据规范,所有二进制数据的格式均为大端格式。
function padCounter(counter) { const buffer = new ArrayBuffer(8); const bView = new DataView(buffer); const byteString = '0'.repeat(64);

最后,准备好密钥和计数器后,就可以生成哈希了! 为此,我们将使用SubtleCrypto
的sign
函数。
const counterArray = padCounter(counter); const HS = await Crypto.sign('HMAC', key, counterArray);
至此,我们已经完成了第一步。 在输出中,我们得到一个有点神秘的值,称为HS。 尽管这不是变量的最佳名称,但在规范中将其称为变量(以及以下某些变量)。 我们将保留这些名称,以便更轻松地与代码进行比较。 接下来是什么?
步骤2:生成一个4字节的字符串(动态截断)
令Sbits = DT(HS)// DT,定义如下,
//返回一个31位的字符串
DT代表动态截断。 这是它的工作方式:
function DT(HS) {

注意我们如何将“与”运算应用于HS的第一个字节。 二进制系统中的0x7f
是0b01111111
,因此本质上我们只是丢弃第一位。 在JS中,此表达式的含义在此结束,但在其他语言中,它也将切断符号位以消除正/负数之间的混淆,并将此数字显示为无符号。
快完成了! 剩下的只是将从DT获得的值转换为数字并前进到第三步。
function truncate(uKey) { const Sbits = DT(uKey); const Snum = parseInt(Sbits, 2); return Snum; }
第三步也很小。 所有需要做的就是将结果数除以10 ** ( )
,然后进行该除法的其余部分。 因此,我们从该数字中切除了最后N个数字。 根据规范,我们的代码应该能够提取至少六位数的密码,并可能提取出七位数和八位数的密码。 从理论上讲,由于这是一个31位数字,因此我们可以提取9个字符,但实际上,我个人从来没有见过超过6个字符。 那你呢
最终功能的代码结合了所有先前的代码,在这种情况下,将类似于以下内容:
async function generateHOTP(secret, counter) { const key = await generateKey(secret, counter); const uKey = new Uint8Array(key); const Snum = truncate(uKey);
万岁! 但是,现在如何检查我们的代码是否正确?
测试中
为了测试实现,我们将使用RFC中的示例。 附录D包含密钥"12345678901234567890"
测试值和从0到9的计数器值。还包含HMAC哈希计数和Truncate函数的中间结果。 对于调试算法的所有步骤非常有用。 这是此表的一个小示例(仅保留计数器和HOTP):
Count HOTP 0 755224 1 287082 2 359152 3 969429 ...
如果您尚未观看演示 ,那么现在是时候了。 您可以在其中驱动RFC中的值。 再回来,因为我们正在开始TOTP。
托普
因此,我们终于到了2FA的更现代的部分。 当您打开一次性密码生成器并看到一个小的计时器,该计时器会计算还有多少有效代码,那就是TOTP。 有什么区别?
基于时间的意思是当前时间代替静态值用作计数器。 或者,更准确地说,是“间隔”(时间步长)。 甚至是当前间隔的数量。 为了计算它,我们使用Unix时间(自UTC 1970年1月1日午夜以来的毫秒数),然后用窗口除以密码有效期(通常为30秒)。 由于时钟同步不完善,服务器通常容忍很小的偏差。 通常来回1个间隔,具体取决于配置。
显然,这比HOTP方案安全得多。 在有时间限制的方案中,即使未使用有效代码,它也会每30秒更改一次。 在原始算法中,有效密码由服务器+公差窗口上的当前计数器值确定。 如果您不进行身份验证,密码将不会无限期更改。 您可以在RFC6238中阅读有关TOTP的更多信息。
由于基于时间的方案是原始算法的补充,因此我们无需更改原始实现。 我们将使用requestAnimationFrame
并检查每个帧是否仍在时间间隔内。 如果不是,请生成一个新的计数器并再次计算HOTP。 省略所有控制代码,解决方案如下所示:
let stepWindow = 30 * 1000;
结束语-QR码支持
通常,当我们配置2FA时,我们会使用QR码扫描初始参数。 它包含所有必要的信息:选定的方案,密钥,帐户名,提供者名称,密码中的位数。
在上一篇文章中,我讨论了如何使用getDisplayMedia
API直接从屏幕扫描QR码。 根据这些资料,我制作了一个小图书馆,我们现在将使用它。 该库称为stream-display ,此外,我们还使用了出色的jsQR包。
QR编码的链接具有以下格式:
otpauth://TYPE/LABEL?PARAMETERS
例如:
otpauth://totp/label?secret=oyu55d4q5kllrwhy4euqh3ouw7hebnhm5qsflfcqggczoafxu75lsagt&algorithm=SHA1&digits=6&period=30
我将省略设置启动屏幕捕获和识别过程的代码,因为所有这些都可以在文档中找到。 相反,这是解析此链接的方法:
const setupFromQR = data => { const url = new URL(data);
在现实世界中,秘密密钥将是一个以32为基数(!)的字符串,因为某些字节可能无法打印。 但是为了简化演示,我们忽略了这一点。 不幸的是,我找不到为什么使用base-32或仅使用这种格式的信息。 显然,此URL格式不存在官方规范,该格式本身由Google创造。 您可以在这里阅读有关他的一些信息。
要生成测试QR码,我建议使用FreeOTP 。
结论
仅此而已! 再一次,不要忘记观看演示 。 还有一个到存储库的链接,其背后是所有代码。
今天,我们已经分解了每天使用的一项相当重要的技术。 希望您自己学到了一些新知识。 这篇文章花了比我想象的更长的时间。 但是,将纸张规范变成可行且熟悉的东西非常有趣。
待会见!