NaCl密码学程序库应用和研究

664 阅读9分钟

笔者在研究JS密码学库的过程当中,无意发现了这个NaCL这个密码学库,简单研究了一下觉得相当有特点,这里分享给大家相关的感受和经验。

概述

NaCL(Networking and Cryptography library网络和加密库),按照其官方的说法是这样的:

NaCl (pronounced "salt") is a new easy-to-use high-speed software library for network communication, encryption, decryption, signatures, etc. NaCl's goal is to provide all of the core operations needed to build higher-level cryptographic tools. Of course, other libraries already exist for these core operations. NaCl advances the state of the art by improving security, by improving usability, and by improving speed.

NaCl是一个易用的、高性能软件库,可用于网络通讯场景中的加密、解密、签名等。其目标是提供用于构建高级密码学工具的所有核心功能。当然,其他的软件包已经提供了这些核心操作,但是NaCl的先进性在于其在安全性、可用性和速度方面的改进。

NaCL有很多变种和不同的平台系统的实现,我们其实最关注其在Web平台的表现。因此我们一般使用的,是其JS实现版本nacl.js。更具体一点是这个nacl-fast.min.js。这个库是一个纯JS实现(这是一个高性能但稍大的版本),没有第三方依赖(使用使用中可能需要编写一些用于编转码的辅助函数)。和一些常规的库如sjcl,cryptojs等相比,它显得有点另类,当然也有一些很好的特点:

  • 功能精干,它并没有提供眼花缭乱的功能、参数和配置,但非常克制的组合提供了一整套很实用和完善的密码学应用方案,包括对称和密钥协商加密,签名和验证,密钥生成和导入等等
  • 性能出众,在一般性性能测试当中,它的综合性能比较好,考虑到其是一个纯JS的实现,其性能通常好于其他JS实现,接近原生crypto和webcrypto的性能
  • 安全先进,它选择了相对更先进和安全的算法和设置,如流加密,加密验证等等
  • 简单精巧,从应用的角度,其隐藏了大量不必要的技术细节,开放、部署和使用非常简单,基础库也很小,min版本只有32K
  • 纯JS实现,可以无缝的在Nodejs和浏览器中运行,并且可以很好的支持移动平台

上述特点,注意来源于其技术选型和核心算法,主要包括:

  • xsalsa20-poly1305: xsalsa20是一种安全性很高的流加密算法,poly1305是其配套的高性能消息验证算法,两者结合构成了一个高效安全的消息加密认证技术方案
  • x25519: 是一种密钥交换协议,基于Curve25519椭圆曲线,可以用于密钥协商和签名验证。和普通的ECC算法曲线相比,Curve25519的安全性和性能表现比较好。
  • x25519-xsalsa20-poly1305: 结合了密钥协商和加密,并简化了开发和使用
  • ed25519: 签名算法的实现
  • sha512: 内置提供了SHA512算法

这些可以帮助我们在后续的应用中更好的理解和应用。下面我们依次讨论其相关的对称加密、密钥协商加密、签名和验证、摘要和消息验证码计算等常见的密码学操作和实现。

我们讨论和测试的代码基于以下版本:

cdnjs.cloudflare.com/ajax/libs/t…

对称加密

NaCl的对称加密在实现中被称为“安全盒”(secretbox,对应非对称加密模型被称为"盒",box)。由于是流加密算法,可以用它提供的随机算法生成初始随机数,结合密钥进行加密。

我们对这一流程进行了简单的封装,输入参数是json对象和加密密钥(Uint8Array或者base64形式),在内部进行随机化,加密的结果,是一个带有初始随机数和加密结果的Base64字符串。这里codec是我们自用的通用编码器,详见站内的另一篇blog。

解密的操作基本上是加密的逆运算,调用secretbox.open方法,参数也是加密内容和随机数,来自分离的原始加密结果;然后将其转换为JSON对象。

从NaCl的原始实现我们知道,这一过程的核心算法是xsalsa20-poly1305,结合随机信息的使用,可以提供较高的加密过程安全和可信性。

const 
{
    box, secretbox, randomBytes,
    sign, verify, hash
} = require("./lib/nacl-fast.min");


// if provide pkey then box 
const encrypt = (json, key, pkey = null) => {
    // key or vkey with pkey 
    if (!(key instanceof Uint8Array)) key = codec.from(key,"base64"); 
    if (pkey && !(pkey instanceof Uint8Array)) pkey = odec.from(pkey,"base64");

    // nonce and content
    const nonce = randomBytes( pkey ? box.nonceLength : secretbox.nonceLength);
    const content = codec.from(json);

    // public key 
    let encrypted = pkey 
        ? box(content, nonce, pkey, key) 
        : secretbox(content, nonce, key) ;
  
    // merge message from box and nonce 
    const bufAll = new Uint8Array(nonce.length + encrypted.length);
    bufAll.set(nonce);
    bufAll.set(encrypted, nonce.length);
  
    // encode to base64
    return codec.to(bufAll,"base64");
};

// key as array or base64
const decrypt = (dcontent, key, pkey = null) => {
    // key or vkey 
    if (!(key instanceof Uint8Array)) key = codec.from(key,"base64"); 
    if (pkey && !(pkey instanceof Uint8Array)) pkey = codec.from(pkey,"base64");

    // split to nonce and content
    const bufAll = codec.from(dcontent,"base64"); 
    const nonce   = bufAll.slice(0, pkey ? box.nonceLength : secretbox.nonceLength);
    const content = bufAll.slice(nonce.length);
  
    // decrypt
    const decrypted = pkey 
        ? box.open(content,nonce, pkey, key) 
        : secretbox.open(content, nonce, key);
  
    return decrypted ? codec.to(decrypted) : null;
};

// genarate key 
const key = randomBytes(secretbox.keyLength);
const obj = { hello: "世界", t: Date.now() };

// encrypt and decrypte
const etext64   = encrypt(obj, key);
const decrypted = decrypt(etext64, key);
    
console.log("Encrypted:",etext64, decrypted);


协商加密

NaCl提供了基于密钥协商机制的对称加密。加密的基本算法应该还是前面标准的对称加密,但加密使用的密钥,是双方各自根据自己的私钥和对方的公钥协商出来的。NaCl单独实现了这一点(道理上只需要提供协商就可以了),其称之为“盒”(box)。

其实前面对称加密方法,已经实现了协商加密,即如果提供了第三个参数-公钥,则第二个参数就是私钥,将会调用box的加解密方法,否则使用secretbox。 因为其他的信息处理方式(如随机信息产生、合并分拆、编码解码、输入输出等等),都是一模一样的,完全可以共用代码。这里还需要注意,这里使用的密钥是使用box.keyPair()方法生成的,当然可以预生成后,转换并加载。

要单独获取协商的密钥,可以使用box.before方法(对方公钥,己方私钥):

let kk = box.before(kpc.publicKey, kps.secretKey);

签名和验证

由于使用非常简单,我们这里没有进行封装。我们来看着这个调用过程:


// signKey and matched publicKey
// let kp1 = sign.keyPair.fromSecretKey(skey);
// let kp1 = sign.keyPair();

let msg = codec.from("China中国","utf8");
    
// sign
let signKey = "MYTessJmSRvjrzLsjpss8smJ2xa7bDxe1O+13xpdzr99+Z9NFtyF26y5BrZTPUaGgpZNb0fLpu8bf85eCBj3Gg==";
let sig = sign.detached(msg, codec.from(signKey, "base64"));

// verify 
let pubKey  = "ffmfTRbchdusuQa2Uz1GhoKWTW9Hy6bvG3/OXggY9xo=";
let vresult  = sign.detached.verify(msg, sig, codec.from(pubKey,"base64"));

console.log("Sign", vresult, codec.to(sig,"base64"));

此处,用于签名的私钥,可能由sign.keyPair()方法生成,也可以来源于一个base64字符串,但这个字符串必须是由keyPair方法生成的私钥进行编码的,同时也需要输出公钥用于验证。当然,在签名和验证之前,也需要将内容编码为uinta。 我们还应该注意到, 这里使用了sign.detached和sign.detached.verify方法进行签名和验证。这个detached是“分离”的意思,要理解这个方法,我们来查看一下另外一种使用方法:


let sig2 = sign(msg, codec.from(signKey, "base64"));
let m2 = sign.open(sig2, codec.from(pubKey, "base64"));
let r2 = verify(msg, m2);

console.log("Sign2", r2, codec.to(sig2,"base64"));

和一般的用法不同,我们发现,可以直接使用sign方法,但它的结果其实是合并了签名和消息(签名消息); 然后在验证方,可以使用sign.open,从签名消息中获取签名;最后使用verify方法,来对比原始消息和签名消息分离出来的内容,来完成签名验证。和一般的理解不同,这里提供的verify方法只是用来安全的验证两个字节数组是不是内容一致。

这样看来,前面的detached,是可以单独使用的意思,即只获得签名,而非签名合并消息,在使用的时候,应该注意到这一点。猜想这样处理的方式是可以将签名和内容一起传输和处理,所以我们看到消息的内容越长,签名消息就越长。

看来,如果没有特别的要求,还是使用detached模式吧。

基于上面的理解,我们也可以封装一个可以用于签名和验证的方法:


// warped  sign and verify same function
const msign = (msg, key, sig)=>{
    // content 
    if (!(msg instanceof Uint8Array))  {
        msg = codec.from(msg, (typeof msg === "object") ? "json": "utf8");
    };

    // key 
    if (typeof key === "string") key = codec.from(key,"base64");

    // sig or verify mode
    if (sig) { // verify, key as public key
        if (typeof sig === "string") sig = codec.from(sig,"base64");
        return sign.detached.verify(msg, sig, key); // return verify result
    } else {
        sig = sign.detached(msg, key);
        return codec.to(sig,"base64"); // return base64
    }
}

// test code
let msg = { hello: "世界", time: Date.now() };
let sig3 = msign(msg, signKey);
let r3   = msign(msg, pubKey, sig3);
console.log("Sign3", r3, sig3);

建议使用此方法,因为这个方法的的改进之处在于:

  • 签名和验证使用同一个方法
  • 如果有第三个参数,则是验证模式(使用公钥),否则为签名模式(使用私钥)
  • 内容消息在两种模式下是一样的
  • 改进的方法内容可以是JSON或者字符串(内置字节数组转换)
  • key和sig可以是base64
  • 返回的签名值是base64,长度为512(可能是sha512算法吧)

HMAC-512

NaCL内置提供了SHA512摘要函数,但没有提供对应内置的HMAC函数。如果有需要,按照HMAC的标准定义和算法(下图),我们应该可以方便的编写和实现HMAC-512消息验证码的生成程序。 需要注意里面几个参数和常数的选择,需要查看和符合标准的定义,才能得到和标准相同的结果。

3d787e53d1692fffec2a67771666b06024679b34.svg

参考实现的JS代码如下:

// hmac use sha512
const 
BK_LEN = 128,  // hmac512 block/key length as standard
IN_LEN = 64,   // hmac512 outer length as standard
V_IN   = 0x36, // xor const inner
V_OT   = 0x5c; // xor const outer

// key as utf8 string or uintarray
const hmac512 = (message, key)=>{    
    // use hash key or padding key
    if (typeof key == "string") key = new TextEncoder().encode(key);
    if (key.length > BK_LEN) { // exceed use hash
        key = hash(key);
    } else if (key.length < BK_LEN){ // padding to ken length
        key = new Uint8Array([...key, ...new Uint8Array(BK_LEN - key.length)]);
    }

    // Append the innerKey with data
    let data = typeof message === "string" ? new TextEncoder().encode(message) : message; 
    let inner = new Uint8Array(data.length + BK_LEN);
    inner.set(key.map(v=>v^V_IN)); // set inner key
    inner.set(data, BK_LEN); // expand inter key with data

    // Append outerkey with hash inner
    let outer = new Uint8Array(BK_LEN + IN_LEN);
    outer.set(key.map(v=>v^V_OT)); // set outer key 
    outer.set(hash(inner), BK_LEN); // expand outer key to length

    // Hash the result
    return hash(outer);
}

// useage 
const h512 = hmac512("hello世界", "密码");
// 593d5a5c8364a5610d025f2e9aa126136e9bd9a4ce49397ded99fcda1c3639c9
// 4ac7e52e06c69854fb0a9f78a2403477c4f2201a4551c83af5997428884946dd