一个简单的对数据有效性进行验证的办法。

最近在一个项目中跟某金融行业的公司有些合作,他们除了要求通讯使用 HTTPS 之外,还对数据的绝对安全性有一定要求,安全性要求有两方面:

  1. 数据本身不要求特别的加密措施,只是必须确认提交数据的真实性。
  2. 数据来往的双方服务器身份必须能够得到有效识别(https 依然有中间人攻击风险)。
  3. 单个请求的数据在特定时间(目前暂定10秒)之后,就必须失效。

背景知识

  • 非对称加密

对称加密指的是双方知道密码就能够解开密文,得到真实信息。而与之对应的非对称加密,即使知道密码也未必能解开密文,它由两部分组成,一部分是用于加密数据的公钥,另外一部分是用于解密数据的私钥,加密数据的公钥可以随意传播给任何一个人,但是解密数据的私钥只能由所有者持有,别人使用公钥加密的数据,只能由所有者通过私钥解密。

  • 数据散列(hash)

将一段任意长度的内容,经过某种算法,转换成一个固定长度的字符串,但是这种转换是有损的,转换后的字符串没有办法再转回原始内容,这种算法有一个特征是内容中发生任意的细微改变,都将导致结果的巨大不同,通常用于验证数据的真实性。

实现方案

现有业务可以完全不动,最终决定在数据上增加一个签名字段,该签名整合了对方的名称、当前时间戳、和使用 SHA1 256 算法生成的消息内容散列数据,还有一个增加破译复杂度的盐,然后用 RSA 非对称算法进行签名。

具体代码如下,只使用了 openssl 生成公私钥,以及 node 的 crypto 模块:

#!/bin/env node

const crypto = require('crypto');

// 私钥内容,用于解密签名
// 使用 openssl genrsa -out rsa.private 1024 生成
const PRIVATE_KEY = `-----BEGIN RSA PRIVATE KEY-----
MIICXAIBAAKBgQDdAwx2Srt4i9363PL+RK6o/LF58cbAwX9+IVoQGmBvf8jDJZqA
CkOBwdRo0LSeTQRBhe5HuuFS3VpatFIoghq069RfeQfOCxH+qzNqYQ+nI5Er3+FK
9rZFqfbWjZt7GxSfCjZkcikC1z2mgetleUMow0Pi7SuV0PadbqYt4cC16QIDAQAB
AoGBAKWGEgBKKiu3PRIUBp0uXU1Mq7Lzw/I7OTwCyIwE5TK8lmSpNhQtG7ADtgym
Oo/QiJ52KyZnrTe9dl02bc3O2yY+lG1gFeP52zYq0dcAucJqHwyIMPhPsxANgZHX
w+8Y3F5L124MWnZ1YOL9ckNDa7sni8OqZNKgQtu8fAmdTPQBAkEA8xtK571bZ5XO
3HTqOcbGFy3xe+MHndG24QgNdTeedX1zNOnfVtSOEQa53AK9ImKjMZBd3VlXq+2o
GMNa2gWYaQJBAOi7xZg560iDPk/qPscJRgDTyfibAFaV5YNXnPgV4iPWlmn8/Kvr
vLmd79fyTYaps8SZ65Rz7DjQwSQBMyXSgYECQDDB0IwZ1jM4QHzGlhNwYlpTxJLs
PaLRZLRNQSW5Ofamamy6Wyi3CKcxiiUuB3DWB5TxN2IlgQfiakxNIfOIG8ECQHVT
zD583Hd26qABGFrg+vCJ1KVHBvmfodAACDstVQ76LGQMTRkiw8bTr0kvdxPvU5hG
fHQfqLPP0b6j+DQWFoECQCKLSQIGoJIJ+BQPcWpsO7T4zJxSd2gbvMIsKydtn52q
c44/dX59oXkDt2gvDSrGY4Lj/RswNK652ymBIfgKfkI=
-----END RSA PRIVATE KEY-----`;

// 公钥内容,用于加密签名
// 使用 openssl rsa -in rsa.private -pubout -out rsa.pub 生成
const PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDdAwx2Srt4i9363PL+RK6o/LF5
8cbAwX9+IVoQGmBvf8jDJZqACkOBwdRo0LSeTQRBhe5HuuFS3VpatFIoghq069Rf
eQfOCxH+qzNqYQ+nI5Er3+FK9rZFqfbWjZt7GxSfCjZkcikC1z2mgetleUMow0Pi
7SuV0PadbqYt4cC16QIDAQAB
-----END PUBLIC KEY-----`;

// 加密的信息
const MESSAGE = { message: 'Hello World' };

// 信息散列方式
const MESSAGE_HASH_TYPE = 'sha256';

// 应用名称,长度不定,双方知道即可
const APP_NAME = 'testApp';

// Salt,盐,双方约定增加破解难度的随机数,为 32 字节长度
const SALT = 'd41d8cd98f00b204e9800998ecf8427e';

// 签名超时时间,按秒计算,
const TIMEOUT = 10;

// 签名类型
const SIGNATURE_TYPE = 'RSA-SHA256';

// 签名输出类型
// see https://nodejs.org/dist/latest-v7.x/docs/api/crypto.html#crypto_sign_sign_private_key_output_format
const SIGNATURE_OUTPUT_TYPE = 'base64';

/**
 * 签名函数
 * 
 * @param {privateKey} string 加密用私钥
 * @param {appName} string 加密内容
 * @param {salt} string 盐
 * @param {message} string | object | array 消息内容
 * @returns string 加密后的签名字符串
 */
function sign(privateKey, appName, salt, message = '') {
  // 当前时间的 unix 时间戳
  const timestamp = parseInt(new Date().getTime()/1000);

  // 生成信息散列
  const json = JSON.stringify(message);
  const hash = crypto.createHash(MESSAGE_HASH_TYPE);
  hash.update(json);
  const messageHash = hash.digest('hex');

  // 生成加密的字符串
  const signStr = appName + timestamp + salt + messageHash;
  console.log(`String will sign is - ${signStr}`);

  // 加密内容
  const signer = crypto.createSign(SIGNATURE_TYPE); // 默认通过 RSA-SHA256 对称算法进行加密
  signer.update(signStr);
  return signer.sign(privateKey, SIGNATURE_OUTPUT_TYPE)
}

/**
 * 签名验证函数
 * 原理是根据加密后的签名,倒推10秒钟,其中一次的签名如果对上了,就算验证成功。
 * 中途最多可能需要生成 10 次加密后的签名,进行对比。
 * 
 * @param {publicKey} string 解密用公钥
 * @param {privateKey} string 生成加密用私钥
 * @param {appName} string 应用名称
 * @param {salt} string 盐
 * @param {signature} string 用于验证的签名
 * @param {message} string | object | array 消息内容
 * @returns boolean 成功为真,失败为假
 */
function validate(publicKey, privateKey, appName, salt, signature, message) {
  // 获取当前时间戳
  const now = parseInt(new Date().getTime()/1000);

  // 生成信息散列
  const json = JSON.stringify(message);
  const hash = crypto.createHash(MESSAGE_HASH_TYPE);
  hash.update(json);
  const messageHash = hash.digest('hex');

  // 从当前时间戳往前推 10 秒,检查签名字符串是否合法
  let count = 0;
  let timestamp = now;
  while(count < TIMEOUT) {
    const signStr = appName + timestamp + salt + messageHash;

    // 生成签名
    const signer = crypto.createSign(SIGNATURE_TYPE);
    signer.update(signStr);
    const newSignature = signer.sign(privateKey, SIGNATURE_OUTPUT_TYPE);

    // 首先检查签名是否正确
    const verifier = crypto.createVerify(SIGNATURE_TYPE);
    verifier.update(signStr);
    const result = verifier.verify(publicKey, newSignature, SIGNATURE_OUTPUT_TYPE)
    // 签名正确后,检查加密后内容是否一致
    if (result) {
      // Signature correct
      if (newSignature === signature) {
        // Content correct
        return true
      }
    }
    count += 1;
    timestamp -=1;
  }

  return false
}

const signature = sign(PRIVATE_KEY, APP_NAME, SALT, MESSAGE);
console.log(`Signature is - ${signature}`);

// 2 秒内签名可用,应该返回 true
setTimeout(() => {
  const result = validate(PUBLIC_KEY, PRIVATE_KEY, APP_NAME, SALT, signature, MESSAGE);
  console.log(`Validate should be correct in 2 seconds - ${result}`);
}, 2000);

// 10 秒后签名失效,应该返回 false
setTimeout(() => {
  const result = validate(PUBLIC_KEY, PRIVATE_KEY, APP_NAME, SALT, signature, MESSAGE);
  console.log(`Validate should be incorrect after 10 seconds - ${result}`);
}, 10000);

执行结果如下:

$ node ./sign.js 
String will sign is - testApp1484759320d41d8cd98f00b204e9800998ecf8427ea5759911f3c834348667ca417f6c8bb4484b58f9af27e91e7c95b208e43761fa
Signature is - uDQgfC2oy+4vndp5/PZA+6vnp4q6iicTFIHDVh6gJZ5Ufkd0/c75Oj3xK/jcKthAOkbNnd3A+CcVzo6aFdKmtPAmsR71ZgoVpJ+5g+kAzz5Gruzt499i9IYxhTQKK5Vo2/swF02n6ShOwWvwVeERUfzzYT9AIuUNhmQJD3+egm8=
Validate should be correct in 2 seconds - true
Validate should be incorrect after 10 seconds - false

可以看到该文本被序列化成 testApp1484759320d41d8cd98f00b... 这样的字符串,进而加密成了一串不可识别的代码,接收方拿到这串代码之后即可验证数据的真实性,当超时时则验证失效,这样做还有个好处是将数据失效的时间交由接收方决定,消息发送方只需要增加签名即可。

这段代码只是阐述一个思考过程,并未充分优化,其实可以异步化将每次验证过程放入消息队列中执行。

欢迎交流。;-)

版权所有丨转载请注明出处:https://kxq.io/archives/一个简单的对数据有效性进行验证的办法