For English version click here.

neoatlantis-crypto-js是我写的一个功能比较全面的、在javascript下实现的加密库。本文将对这一库的各种功能进行讲解。

由于本库内容很多,并在不断开发和调试中,本文将不断更新。因此在标题中附加了这一说明的版本号。本说明还将以英文版撰写。

0 动机

0.1 enigma系统

本库可以说是开发enigma系统的必然结果。为了介绍本库,首先介绍下enigma系统。 enigma系统被设计为一个能运行在嵌入式计算机上的服务程序:

  • 对于嵌入式计算机(之后称为内机)外、通过特定的手段(称为API)输入的请求(外面的计算机称为外机),它可进行如下操作:
    • 将输入的请求展示给用户,在用户批准后,对请求中的数据进行加密和/或签名,并将结果返回给外机。
    • 将输入的请求视为另一台enigma的密文(或签名的明文),解密内容并进行验证,将结果展示给用户,并在用户批准下将明文结果返回给外机。
    • 根据请求和用户批准,将用户自身的某个身份公钥传送给外机。
    • 根据请求和用户批准,导入一个其他用户的身份公钥。
  • 此外,用户可以通过直接与内机上相连的输入输出设备,如键盘、鼠标、屏幕等,进行如下操作:
    • 管理(包括列出、产生、删除、导入、导出)内机上存储的身份密钥(包括公钥和私钥)。
    • 编辑可供发送的草稿。在执行批准外机的请求时,可以用草稿来代替外机给定的数据。
    • 查看已经获取到的、存储的解密数据。
    • 编辑、储存一类特殊的草稿,即对其他用户身份公钥的签署(称为令牌)。

enigma系统可以作为很多通讯工具或者协议的核心部件。 例如,它可以配合聊天软件的插件,实现在聊天软件中的端到端加密。 它也可能作为门禁之类的系统的部件,用户能通过签署给定的“挑战”数据(challenge),可以向门禁系统证明自己的身份。

0.2 neoatlantis-crypto-js库

在开发enigma系统的过程中,作者认识到,有必要将整个系统的涉及到密码学的部分分离出来,单独写成一个库。 在这个库中包含了安全的随机数发生器、对称加密算法、不对称加密算法和签名算法、散列函数等功能, 还包含了用于构建enigma系统的2个数据结构:身份密钥(identity)和消息(message)。

此外,本库的亮点在于,考虑到即使包含了前述这两个数据结构仍然需要大量精力构建一个enigma系统,例如需要处理存储、消息读取中的各种复杂的逻辑, 还包含了一个事实上已经接近完整的enigma系统的抽象实现。只需要根据这个抽象实现的要求,通过自定义的用户界面送入数据,就可以直接得到数据输出。 利用这一抽象实现,用户可以非常快速地构建一个具有enigma系统功能,或者有所缩减(例如自动化一些批准流程)的系统,并且保持其与本系统的兼容性。

简而言之,用户利用neoatlantis-crypto-js库,既可以完成基本的和密码相关的功能,又可以快速实现复杂的、用于端到端加密的逻辑。

0.3 功能列表

这里列出一个neoatlantis-crypto-js的完整的功能介绍。

  • 散列
    • 自带BLAKE2s、WHIRLPOOL、RIPEMD160的散列实现。
    • 所有的散列算法都可以被用来进行MAC(消息认证值,Message Authentication Code)的计算。
    • 所有的散列算法都可以被用来完成PBKDF2密钥推导算法。注意:速度上有劣势。
  • 加密/签名
    • 对称加密 本库自带了AES和Salsa20/20算法,但是不提供直接调用。 本库只提供一个基于如上算法构建的“复合”的对称加密算法。
    • 不对称加密 本库的不对称加密算法不与任何现行标准兼容。这是一个将基于椭圆曲线密码构建的密钥交换算法(ECDH)和签名算法(ECDSA)共同利用起来的算法。 这一算法从一个基础的种子私钥开始,通过PBKDF2推导用于初始化ECDH和ECDSA的两个私钥。 然而特别注意的是,ECDH算法本意是用于密钥交换协议,本库中将ECDH给出的公钥作为公钥, 在向这一公钥加密的数据中附加临时的“响应”密钥(PeerKey)并藉此构建共享秘密(SharedSecret),供对称加密算法加密实际数据。
  • enigma系统
    • 一个身份密钥的数据结构。身份密钥是一个256(不含)字符以内的说明(subject)、一个公钥、一个对公钥和subject进行的签名构成。
    • 一个消息数据结构。消息数据或者是由一个负荷直接构成,或者是一个加密的负荷(称为信封)附加解密信息。
      • 负荷内包含签名者、签名数据和正文
      • 信封内包含加密的负荷数据、解密者和供解密者恢复密钥的数据、以及压缩标记。
    • 一个抽象的enigma系统实现
      • 通过API名称调用功能
      • 功能调用时通过问答形式和调用者交互,问答形式询问变量参数、抛出异常等等。
      • 调用者只需很少的逻辑来将回答交给用户,或自动回答,即可完成特定任务。
      • 自行管理存储(基于localStorage的接口),调用者只需维护一个localStorage即可。
  • 加速模块
    • 根据库所在平台不同,可以调用平台自带的接口实现对某些功能的加速或完善。
    • 例如调用NodeJS的crypto库实现散列函数,或者调用自定义浏览器内核的接口实现更加安全的随机数发生器。
  • 辅助工具
    • 在Javascript的字符串、ASCII字符串、Base32、Base64、Hex编码之间转换。
    • 操作ArrayBuffer,实现拼接、异或、判断相等、反转顺序等功能。
    • 随机字节序列发生器。
    • UUID发生器。
    • 变量类型判断。
    • (未完整实现的)LZW压缩。
    • 一个序列化工具,根据预定义的数据结构将JSON格式的赋值序列化或逆序列化。支持多种类型的数据。

1 基本知识

1.1 初始化neoatlantis-crypto-js库

要初始化本库,可以在NodeJS中使用:

var crypto = require('./lib/neoatlantis-crypto-js.js'); // 指向库的文件地址

在浏览器中,使用RequireJS:

require(['lib/neoatlantis-crypto-js'], function(crypto){...}); // 指向库的文件地址

1.2 一些注意事项

在大多数时候,结果以ArrayBuffer类型的数据输入输出。

要读取其内容,您需要使用类似如下代码:

var result = // 您拿到的ArrayBuffer
var ary = new Uint8Array(result);
ary.length; // 获得数据的长度
ary[0]; // 获得第0位置上的数据 [0, 255]

要根据已有参数得到一个ArrayBuffer

var ary = new Uint8Array([1,2,3,4,4,3,2,1]);
ary.buffer; // ArrayBuffer

2 使用辅助工具函数

crypto.util下提供了一些有用的工具函数。这些也被本库内部调用。

2.1 获得随机数据

最常用的功能是获取n个随机字节:

var x = new crypto.util.srand().bytes(1024);
// x 是得到的包含 1024 随机字节的ArrayBuffer

var y = new crypto.util.srand().bytes(1024);
// y 的类型是一个Uint8Array,可以直接通过序号访问

您可以通过如下代码让产生的随机数更加随机:

var rand = new crypto.util.srand();
rand.touch();

通过touch方法,您将全局地(虽然您使用了new来生成rand)影响随机数发生器的一些内部状态。当它们变化后,产生的随机数就更加不可预料了。 您可以将这个方法与用户鼠标、键盘等各种输入结合起来。进行touch的时机才是影响随机数发生器的参数。和您具体采用了什么输入无关。

2.2 处理ArrayBuffer

使用crypto.util.buffer,可以对ArrayBuffer类型的数据进行各类处理。

异或。要将两个byteLength相等的ArrayBuffer异或,使用如下代码:

var buffer1 = ...
var buffer2 = ... // buffer1, buffer2 都是 ArrayBuffer
var output = crypto.util.buffer.xor(buffer1, buffer2);
// buffer1, buffer2的byteLength属性必须一样。output也是一个ArrayBuffer
// 如果buffer1.byteLength != buffer2.byteLength,将会抛出异常

拼接。要将多个ArrayBuffer按次序拼接在一起,使用如下代码:

var list = [buffer1, buffer2, buffer3, ..., bufferx]; // list[i]都是ArrayBuffer
var output = crypto.util.buffer.concat(list); // 获得一个新的ArrayBuffer

判断相等。判断两个ArrayBuffer的内容是否一致。使用如下代码:

var buffer1 = ...
var buffer2 = ... // buffer1, buffer2 都是 ArrayBuffer
var output = crypto.util.buffer.equal(buffer1, buffer2);
// 仅当buffer1和buffer2都是ArrayBuffer,并且所包含的数据完全一致,才返回true
// 其他任何情况,都返回false

反序。将ArrayBuffer内第一项和最后一项对调,第二项和倒数第二项对掉,以此类推。

var output = crypto.util.buffer.reverse(buffer); // buffer和output都是ArrayBuffer

2.3 判断变量类型

使用如下代码,可以判断一个变量是否属于特定的类型:

var variable = ...
var typeTest = crypto.util.type(variable);
// typeTest.isArrayBuffer() 测试是否为ArrayBuffer

// 可供使用的有:
// isError(), isArray(), isObject(), isPrimitive(), isFunction(), isDate()
// isNumber(), isString(), isBoolean(), isArrayBuffer()

2.4 转换编码

使用如下代码,可以将字符串或ArrayBuffer作为输入,得到改变编码后的输出。

var converter = crypto.util.encoding(inputVariable, 'ENCODING');
// inputVariable是一个字符串或者一个ArrayBuffer

// ENCODING在inputVariable为字符串时指定,可能的值有:hex, ascii, base64, base32
// 这将指导程序如何理解输入的字符串,将其识别为字节流。如果不指定,将按照普通字符串读取。
// ascii编码类型表示,输入的字符串只包含ascii码表中的字符。
// hex编码类型表示,输入字符串是偶数长度,并且只有0-9和a-f字符构成。

converter.toArrayBuffer(); // 得到一个ArrayBuffer作为输出
// 其他类似的功能:toHEX(), toUTF16(), toBase64(), toBase32(), toASCII(), toArray()

2.5 UUID产生

使用如下代码,将会利用crypto.util.srand产生一个UUID:

var uuid = crypto.util.uuid(); // 返回一个类似 7c21b2c8-6370-13e4-bef9-3c280e55a5cf 的字符串

2.6 序列化/逆序列化工具

本库自带了一个序列化一个特定数据结构的工具。利用这个工具,您可以自定义一个数据结构,称为“模板”,然后填充数据,得到序列化的结果,当然也可以读取。

您可以通过类似给javascript的Object的属性(Attribute)赋值一样的方式构建数据。在序列化之后的结果中,各属性的Key并不被包含。 所以逆序列化需要您已知目标数据属于哪个模板。

在一个数据结构中,您可以使用如下类型的数据给属性赋值:

  • binary, 二进制字节流。具体支持3种:shortBinary, binary, longBinary
    • shortBinary 占用1个字节标记字节流的长度,最大长度是255个字节,允许为空。
    • binary 占用2个字节标记字节流的长度,最大长度是65535字节,允许为空。
    • longBinary 占用4个字节标记字节流的长度,最大长度是2^32-1字节(约4GB),允许为空。
  • boolean,一个布尔类型的变量,占用一个字节。
  • constant,将给定的ArrayBuffer的数据放入序列化的结果中。根据ArrayBuffer的长度确定占用的字节长度。在逆序列化时要求期望的结果与此一直,否则会抛出异常。
  • enum,允许输入数据为给定的字符串列表中的一项,最多可以定义255个项目的列表。占用1个字节。逆序列化时将根据输入字节,将输出表示为列表中的一项。
  • datetime,允许输入一个日期并记录。精度为1秒,占用7个字节。可以记录年份0-65535年。
  • array, 二进制字节流的数组。支持shortArray, array,分别支持255、65535项记录。 注意shortArray支持的二进制字节流也必须是shortBinary,而array支持的二进制字节流也必须是binary
2.6.1 定义一个序列化模板

使用如下代码,构建一个序列化的模板:

// 摘自enigma/identity.js
var template = {
    '_': ['constant', new Uint8Array([69, 73]).buffer],
    'subject': 'shortBinary',
    'algorithm': ['enum',
        'NECRAC96',
        'NECRAC112',
        'NECRAC128',
        'NECRAC192',
        'NECRAC256',
    ],
    'public': 'binary',
    'secret': 'shortBinary',
    'signature': 'binary',
};

template是一个javascript的Object,示例中定义了_, subject, algorithm, public, secret, signature等属性。 之后赋值时,利用这些名称即可。但是这些名称不会被包含在最终的序列化结果中。

注意:algorithm是枚举(enum)类型。_是一个常数类型。 在逆序列化时,至少如果在_项目上发现结果不是定义的值,就会报错。由此可以快速简单区分一个正确的和不正确的输入。

2.6.2 序列化一段数据

使用如下代码,利用给定的模板序列化一段数据。

var serializer = crypto.util.serialize(template); // 由template模板构建一个序列化工具

// 输入数据,常数`_`不必输入
var data = {
    'subject': new Uint8Array([...]).buffer,
    'algorithm': 'NECRAC96',
    'public': new Uint8Array([...]).buffer,
    'secret': new Uint8Array([...]).buffer,
    'signature': new Uint8Array([...]).buffer,
};

//获得序列化结果
var result = serializer.serialize(data); // result是个ArrayBuffer
2.6.3 逆序列化一段数据

使用如下代码,将一段ArrayBuffer类型的数据逆序列化。

var deserializer = crypto.util.serialize(template); // 由template模板构建一个逆序列化工具

//获得逆序列化结果
var result = deserializer.deserialize(data); // data应当是ArrayBuffer

请注意,逆序列化时可能抛出异常,请用try-catch处理。一般这标志着数据不符合给定的模板。

3 散列

本库提供一个散列函数的接口,可以进行对数据的散列、MAC(Message Authentication Code,消息认证码)的计算,以及一个速度很慢的PBKDF2实现。 散列函数只提供Whirlpool一种。输出长度可以从1字节到64字节之间选择。

3.1 计算一个ArrayBuffer的散列

利用如下代码,根据一个ArrayBuffer,计算散列:

var ary = ... // ArrayBuffer
var hasher = new crypto.hash(OUTPUTLENGTH); // OUTPUTLENGTH为想要的散列函数截断长度

var result = hasher.hash(ary);
result.buffer; // 得到一个`ArrayBuffer`
result.hex; // 得到一个用十六进制(0-9, a-f)表示的结果字符串

3.2 计算一个ArrayBuffer的MAC

MAC是一种在计算散列之前需要一个额外的密钥输入的算法。可以用来进行比如带密钥的验证消息完整性等操作。

var ary = ... // ArrayBuffer
var key = ... // ArrayBuffer

var hasher = new crypto.hash(32); // 设定输出长度为32个字节
var result = hasher.mac(ary, key);
result.buffer; // 得到一个`ArrayBuffer`
result.hex; // 得到一个用十六进制(0-9, a-f)表示的结果字符串

3.3 进行PBKDF2密钥推导

PBKDF2密钥推导是一种消耗计算资源的算法,可用来将较弱的口令(用户的直接输入)通过消耗计算机时间而加强,可以抵御暴力破解密码,并提高结果的随机性。

var key = ... // ArrayBuffer
var salt = ... // ArrayBuffer
var iterations = 10000; // 数字越大越安全,但是计算机消耗时间越多
var len = 30; // 结果的字节数

var hasher = new crypto.hash();
var result = hasher.pbkdf2(key, salt, iterations, len);
result.buffer; // 得到一个`ArrayBuffer`
result.hex; // 得到一个用十六进制(0-9, a-f)表示的结果字符串

PBKDF2的算法内部,需要调用由所选定的散列函数构成的MAC算法。这将由3.2所示的MAC算法构成。 在内部调用这些MAC算法时,MAC的结果并不被截断,即,使用的是它们的完整长度的输出。

4 对称加密

本库提供一个单一的对称加密接口。输入密钥和数据,就可以实现加密或者解密。加密的对象是任何已知长度的数据块。

这一对称加密算法包含如下步骤,以加密为例:

  1. 选取10比特的salt
  2. 由输入的密钥推导产生用于各实际加密算法的密钥
  3. 由散列算法计算明文的MAC(使用一个第2步产生的密钥),长度6字节,附加到明文开头。
  4. 对产生的明文首先进行补充长度至16字节的整数倍(RFC5652),然后先利用Salsa20/20算法,再利用AES-128-ECB算法进行加密。

由于使用了Salsa20/20算法,继续使用ECB模式的AES-128并不会导致正常来说ECB模式的安全问题。本算法使用MAC验证解密的成功与否。

为了使用对称加密:

var encryptor = new crypto.cipher.symmetric();
encryptor.key(key); // key 为ArrayBuffer
encryptor.encrypt(data); // data 为ArrayBuffer,返回ArrayBuffer形式的结果

为了解密:

var decryptor = new crypto.cipher.symmetric();
decryptor.key(key); // key 为ArrayBuffer
decryptor.decrypt(data); // data 为ArrayBuffer,返回ArrayBuffer形式的结果

5 不对称加密和签名

本库提供的不对称算法有各种“套装”。这些套装的具体实现和输出格式不遵循现有的标准。

每套算法有一个名称,指定了这个名称,就指定了具体用来加密和签名的算法(和相关的参数),以及用来签名之前的散列函数等等。 由于这一定义,每套算法都可以用来加密和签名。此外,为了密钥管理上的方便,用户的私钥总是一个可以指定的一定字节长度的随机数据。 在私钥指定后,给定算法,可以推导出公钥。

目前支持的算法套装如下:

名称 加密算法 签名算法 散列算法 私钥长度(bit)
NECRAC256 ECDH/secp521r1 ECDSA/secp521r1 WHIRLPOOL 64 x 8 = 512
NECRAC192 ECDH/secp384r1 ECDSA/secp384r1 BLAKE2s 48 x 8 = 384
NECRAC128 ECDH/secp256k1 ECDSA/secp256r1 BLAKE2s 32 x 8 = 256
NECRAC112 ECDH/secp224r1 ECDSA/secp224r1 RIPEMD160 28 x 8 = 224
NECRAC96 ECDH/secp192r1 ECDSA/secp192k1 RIPEMD160 24 x 8 = 192

在选择了任何一个套装之后,得到的加密程序都能处理任意的输入,对其加密/解密/签名/验证签名。

5.1 初始化一个不对称加密对象

使用如下代码,根据选取的算法套装,初始化一个不对称加密的对象。

var algorithm = 'NECRAC128'; // 见上文列出的算法名
var asym = crypto.cipher.asymmetric(algorithm);

在初始化之后,asym有2个方法可供进一步调用,即setPrivateKeysetPublicKeysetPrivateKey用来设定一个私钥。其参数是被随机的字节填充的ArrayBuffer类型的数据,长度由上文表格确定。 setPublicKey用来设定一个公钥,由下文所述的导出公钥的方式获取,其变量类型也是ArrayBuffer

调用这2个方法之一后,asym将会暴露其他的方法以供进一步操作。 同时,asym将删去已经暴露的setPrivateKeysetPublicKey方法,禁止重新进行初始化操作。

5.2 使用不对称加密对象

设定了公钥或者私钥之后的asym对象,可以被用来进行如下操作:

5.2.1 推导公钥

在指定了一个任意的私钥之后,使用算法推导给出对应于这个私钥的公钥。 之后这个公钥就可以被公开,以便让他人藉此向私钥持有者加密发送消息,或者公钥持有者验证私钥持有者签署过的消息。

var publicKeyBuf = asym.getPublicKey(); // 返回 ArrayBuffer

为此,asym对象必须是通过setPrivateKey方法初始化的。

5.2.2 加密

使用加密方法,可以在得到别人的公钥之后,向他发送加密的数据。只有持有和公钥对应的私钥的人才能解密数据,其他人不能。

var plaintextBuf = ... // ArrayBuffer
var ciphertextBuf = asym.encrypt(plaintextBuf); // 返回 ArrayBuffer

使用setPrivateKeysetPublicKey方法通过输入私钥或者公钥初始化的asym对象都可以调用这一功能。

5.2.3 解密

已知一个私钥,解密别人发来的数据。

var plaintextBuf2 = asym.decrypt(ciphertextBuf); // 返回 ArrayBuffer

只有使用setPrivateKey方法通过私钥初始化的asym对象可以调用这一功能。

5.2.4 签名

签名的功能是对给定的plaintextBuf输入,生成一段数据,其他人拥有公钥的时候,可以据此确定确实是私钥的拥有者进行了“签名”这一操作,而不是任何其他人。 此外,签名者自己不能否定自己曾经签署过这段数据。

var signatureBuf = asym.sign(plaintextBuf); // 返回 ArrayBuffer

只有使用setPrivateKey方法通过私钥初始化的asym对象可以调用这一功能。

5.2.5 验证签名

在得到别人的公钥之后,可以验证此人所签署过的数据。需要输入声称被此人签署过的数据plaintextBuf2和签名数据signatureBuf2

var result = asym.verify(plaintextBuf2, signatureBuf2); // 返回 true 或者 false

使用setPrivateKeysetPublicKey方法通过输入私钥或者公钥初始化的asym对象都可以调用这一功能。 但是自然,一般在实际应用中,有意义的用法是通过输入公钥进行的初始化。

6 Enigma