Jacky Gu

如何使用IPFS实现端到端的加密文件传输

09 Mar 2023 Share to

本文介绍了一种基于区块链钱包的web前端方案,该方案可对大文件进行加密,并通过互联网传输到去中心化存储节点中存储,同时保证端到端传输的安全性。 该方案可解决互联网上文件交换和数据交易的安全性和独占性问题,为数字经济时代的数据确权和流通提供了一个可靠的解决方案。 最后,本文提出了一个结合NFT非同质化资产证明技术的设想,实现数据所有权凭证,为数据确权和流通提供了更可信的方式。 这些方案和设想将在Chatpuppy项目中实现。

作为一个即时聊天应用,需要支持富媒体等大文件的点对点的加密传输,而Chatpuppy作为一款具有web3数据安全理念的聊天Dapp,与常规的加密聊天App相比,更需要解决以下问题:

  • 不能因为要实现点对点加密传输,导致加密文件的过度冗余;
  • 文件大小不能在加密之后变大;
  • 即使文件存在公共的存储服务上(比如IPFS,ARwave等),也能确保安全;
  • 密文要足够安全;
  • 用户使用要足够方便;

为了实现上述要求,我们采取了以下技术组合:

  • 对文件的本体进行对称加密,一个文件对应一个随机的加密密钥;
  • 经过性能对比,放弃了通用性更强的AES128/256算法,改用速度更快的Rabbit算法;
  • 对上述加密密钥,使用接收方的公钥加密,接收方用自己的私钥解密,以确保密文只有发送方和接收方可以获得;
  • 在加密前,先对文件进行压缩,经过对比,选择zstd压缩算法。经测试,即使对jpg文件进行压缩以及加密后,文件大小也只有源文件60-70%;
  • 加密后的文件保存在IPFS上;
  • 上述所有操作在区块链钱包(如以太坊的metamask钱包或闪电网络的alby钱包)进行,一键完成压缩、加密、上传,一键完成下载、加密、解压缩;

实现以上功能后,我们发现该方案不仅适用于web3的加密聊天应用,而且能够解决数字经济时代数据流转和交易过程中确权难、防盗版难的痛点。该方案采用的密码学算法经过广泛应用和验证,安全可靠,并且具有可插拔的特点,即可以方便地替换为其他密码学算法或文件存储方式。这使得该方案能够适应不同的应用场景和需求,具有更大的灵活性和可扩展性。

下面详细解释下实施步骤和相关代码: 因为是前端应用,为了方便理解,使用typescript编程,但可以使用其他任何语言实现相同的逻辑。

加密过程

一共分以下几步:

  • 1- 获得本地文件
  • 2- 压缩文件
  • 3- 生成加密文本的密钥
  • 4- 加密文件
  • 5- 上传到IPFS
  • 6- 发送密钥

1- 获得本地文件

通过一个input组件获取文件对象file。

<div>
    <h2>Encrypt & compress</h2>
    <input type="file" ref={compressFile} onChange={handleCompress} />
</div>

2- 压缩文件

在这里,我们使用了zstd-code库,见:https://www.npmjs.com/package/zstd-codec。 在代码中导入zstd-code:

import { ZstdCodec } from 'zstd-codec';

然后在ZstdCode.run中压缩源文件,普通文件可以压缩到源文件的50%以下。

const file = Array.from(e.target.files)[0];
const fileContent = new Uint8Array(await file.arrayBuffer());
ZstdCodec.run(async (zst: any) => {
  // Compress encrypted data, the file will be smaller
  const simple = new zst.Simple();
  const level = 21;
  try {
    const compressedFile = simple.compress(
      fileContent,
      level
    ) as Uint8Array;
    console.log('original', fileContent, fileContent.length);
  } catch (err) {
    console.log(err);
  }
}

需要注意的是无论是压缩还是之后的加解密,处理的都是文件的Buffer,也就是Uint8Array,把包括文本,图像,视频,二进制文件等在内的所有格式文件都转成Uint8Array,便于统一处理。

3- 生成加密文本的密钥

以下方法可以生成一个128个字符组成的密钥,字符长度可以自定。

  const generatePassword = () => {
    const len = 128;
    const charset =
      'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
    let retVal = '';
    for (var i = 0, n = charset.length; i < len; ++i) {
      retVal += charset.charAt(Math.floor(Math.random() * n));
    }
    setEncryptionPassword(retVal);
    return retVal;
  };

4- 加密文件

我们使用了web端加密最流行的加密库CryptoJS(https://www.npmjs.com/package/crypto-js),通过对比分析,决定使用该库提供的Rabbit对称加密算法,而没有使用最流行的AES算法。

  const encryptData = (data: Uint8Array, pwd: string): Uint8Array => {
    const wa = convertUint8ArrayToWordArray(data);
    const wordArray = CryptoJS.lib.WordArray.create(wa.words, wa.sigBytes);
    var encrypted = CryptoJS.Rabbit.encrypt(wordArray, pwd);
    return getBufferFromCipherParams(encrypted, data.length);
  };

把第二步压缩文件后得到的uint8Array和第三步的密钥作为参数,进行加密。

上面方法中用到了两个转换函数:convertUint8ArrayToWordArraygetBufferFromCipherParams

使用第一个转换函数的原因是CryptoJS库中处理的数据都是32位的WordArray,所以需要把Uint8Array转换成WordArray,具体转化方法如下:

export function convertWordArrayToUint8Array(
  wordArray: CryptoJS.lib.WordArray
) {
  var len = wordArray.words.length,
    u8_array = new Uint8Array(len << 2),
    offset = 0,
    word,
    i;
  for (i = 0; i < len; i++) {
    word = wordArray.words[i];
    u8_array[offset++] = word >> 24;
    u8_array[offset++] = (word >> 16) & 0xff;
    u8_array[offset++] = (word >> 8) & 0xff;
    u8_array[offset++] = word & 0xff;
  }

  // delete the latest 0 elements
  const lastWord = wordArray.words[len - 1];
  const lastWordArray = [
    lastWord >> 24,
    (lastWord >> 16) & 0xff,
    (lastWord >> 8) & 0xff,
    lastWord & 0xff
  ];
  let zeros = 0;
  if (
    lastWordArray[0] === 0 &&
    lastWordArray[1] === 0 &&
    lastWordArray[2] === 0 &&
    lastWordArray[3] === 0
  )
    zeros = 4;
  else if (
    lastWordArray[0] !== 0 &&
    lastWordArray[1] === 0 &&
    lastWordArray[2] === 0 &&
    lastWordArray[3] === 0
  )
    zeros = 3;
  else if (
    lastWordArray[0] !== 0 &&
    lastWordArray[1] !== 0 &&
    lastWordArray[2] === 0 &&
    lastWordArray[3] === 0
  )
    zeros = 2;
  else if (
    lastWordArray[0] !== 0 &&
    lastWordArray[1] !== 0 &&
    lastWordArray[2] !== 0 &&
    lastWordArray[3] === 0
  )
    zeros = 1;
  return u8_array.subarray(0, u8_array.length - zeros);
}

在网上有很多实现该方法的例子,但是都忽略了一点:当Uint8Array长度不是32的倍数时,就会自动用0补足位数,而这些补充的位数会让文件与源文件不同。为此,上述代码中添加了去零的操作。

另外一个方法getBufferFromCipherParams,实现将加密后的cipherParams数据打包成Buffer,因为cipherParams中不仅包括ciphertext,还包括keyivsalt等数据,而在解密时需要这些,所以就不能仅仅导出ciphertext。为此,我们设计了一个数据结构来存放这些数据。

  const getBufferFromCipherParams = (
    cipherParams: CryptoJS.lib.CipherParams,
    originalLength: number
  ) => {
    const ciphertextU8 = convertWordArrayToUint8Array(cipherParams.ciphertext);
    const keyU8 = convertWordArrayToUint8Array(cipherParams.key); // 32
    const ivU8 = convertWordArrayToUint8Array(cipherParams.iv); // 16
    const saltU8 = convertWordArrayToUint8Array(cipherParams.salt); // 8

    // Format of Uint8Array header + body
    // 0-15: original file length of Uint8Array
    // 16-31: ciphertext length of Uint8Array
    // 32-63: keyU8
    // 64-79: ivU8
    // 80-87: saltU8
    // 88~: ciphertext
    const newBuffer = new Uint8Array(88 + ciphertextU8.length);
    newBuffer.set(numberToBytes(originalLength));
    newBuffer.set(numberToBytes(ciphertextU8.length), 16);
    newBuffer.set(keyU8, 32);
    newBuffer.set(ivU8, 64);
    newBuffer.set(saltU8, 80);
    newBuffer.set(ciphertextU8, 88);
    return newBuffer;
  };

上面我们用了最简单的处理方式,即将key, iv, salt原封不动的打包,但在正式版中,会做些安全度更高的优化。

5- 上传到IPFS

这个步骤很简单,有IPFS有专门的库提供这方面的操作。不过我更喜欢使用web3.storage这家服务,这里就不上实现上传的代码了。

6- 发送密钥

发件人用接收人的钱包公钥将对称加密的密钥后,将密文发给接收人。因为这个密文只能用接收人的私钥打开查看,所以密文可以通过非安全的公开渠道发送。

在这里,接收人使用metamaskgetEncryptionPublicKeyRPC方法得到他的公钥,然后通过公开方式交给发件人。发件人使用收件人公钥进行加密。代码如下:

  const getEncryptionPublicKey = async () => {
    const encryptionPublicKey = await window.ethereum.request({
      method: 'eth_getEncryptionPublicKey',
      params: [currentAccount]
    });
    setPublicKey(encryptionPublicKey);
  };
  
  const encryptByPubKey = async () => {
    const encryptData = {
      publicKey,
      data: encryptionPassword,
      version: 'x25519-xsalsa20-poly1305'
    };
    const enc = sigUtil.encrypt(encryptData);
    const buff = stringToUint8Array(JSON.stringify(enc)) as any;
    const encryptedMessage = ethUtil.bufferToHex(buff);
    const compressedEncryptedMessage = hexToString(encryptedMessage);
    setEncryptedPassword(compressedEncryptedMessage);
  };

手动执行上述操作可能有些繁琐,但是如果集成在带有数字钱包的社交应用中,将会大大提高用户体验。在Chatpuppy的聊天应用中,我们已经整合了上述功能,使得公钥的传输、密钥的加密和密文的传输变得非常简单。

解密过程

分以下几步:

  • 1- 下载文件
  • 2- 解密文件
  • 3- 解压缩文件

1- 下载文件

从IPFS上下载加密压缩后的文件,因为有现成的工具库,在此省略代码。

2- 解密文件

这是上面encryptData的反向操作,代码如下:

  const decryptData = (data: Uint8Array, pwd: string): Uint8Array => {
    const cp = parseCipherParamsFromBuffer(data);
    const cipherParams = CryptoJS.lib.CipherParams.create({
      ciphertext: CryptoJS.lib.WordArray.create(
        cp.ciphertext.words,
        cp.ciphertext.sigBytes
      ),
      key: CryptoJS.lib.WordArray.create(cp.key.words, cp.key.sigBytes),
      iv: CryptoJS.lib.WordArray.create(cp.iv.words, cp.iv.sigBytes),
      salt: CryptoJS.lib.WordArray.create(cp.salt.words, cp.salt.sigBytes)
    });

    var decrypted = CryptoJS.Rabbit.decrypt(cipherParams, pwd);
    const decryptedU8 = convertWordArrayToUint8Array(decrypted);
    const result = decryptedU8.subarray(0, cp.originalLength);
    return result;
  };

因为需要把Buffer转成cipherParams,所以有parseCipherParamsFromBuffer方法,具体实现的代码如下:

  const parseCipherParamsFromBuffer = (buf: Uint8Array) => {
    // decrypt from Uint8Array
    const ciphertext = convertUint8ArrayToWordArray(buf.subarray(88)); // convertUint8ArrayToWordArray(ciphertextU8);
    const originalLength = bytesToNumber(buf.subarray(0, 16));
    const cipherLength = bytesToNumber(buf.subarray(16, 32));
    const key = convertUint8ArrayToWordArray(buf.subarray(32, 64));
    const iv = convertUint8ArrayToWordArray(buf.subarray(64, 80));
    const salt = convertUint8ArrayToWordArray(buf.subarray(80, 88));
    return {
      ciphertext,
      key,
      iv,
      salt,
      originalLength,
      cipherLength
    };
  };

另外,因为需要把解密后的WordArray转成Uint8Array,所以也需要有convertWordArrayToUint8Array方法。

3- 解压缩文件

使用下面代码实现解压缩,并将解压缩后的文件保存到本地。

    const decryptedU8 = decryptData(fileContent, encryptionPassword);
    // Decompress
    ZstdCodec.run((zst: any) => {
      const simple = new zst.Simple();
      try {
        const result = simple.decompress(decryptedU8) as Uint8Array;
        if (result !== null) {
          const blob = new Blob([result], {
            type: 'application/octet-stream'
          });
          download(blob, file.name.substring(0, file.name.length - 4));
        } else {
          console.log('Decompress error');
        }
      } catch (err) {
        console.log(err);
      }
    });

上面整个流程的源码可以在:https://codesandbox.io/s/charming-breeze-v1mh97 找到。(注意:在使用codesanbox时,因为平台对浏览器内存做了限制,所以只能加解密500K左右的文件)

关于NFT数据所有权凭证的一点想法

在谈论数字经济和数据资产时,我们总是离不开一个问题:数据是如何确权的?确权是数据的流转、使用和加工的前提。

尽管区块链技术已经成为实现数据确权的最佳工具,但目前仅能对原生区块链数据进行确权,对于链外数据仍无法实现可信的确权。

在这里,笔者提出一个设想,即将加密文件传输与NFT非同质化数字资产技术相结合,或许可以为解决数据确权问题提供一个思路。

方式如下:在NFT的元数据中包含数据发送人用接收人的公钥对文件密钥进行加密后的密文,以及加密文件的URL地址。即该NFT只有发送方授权的接收方才能获得用于解密文件的密钥。当接收方解密文件并将其转发给第三方时,第三方需要通过验证NFT来确认接收方是否拥有相应的数据权益。同时,最早的发送方也可以通过NFT中包含的元数据来控制数据的复制,防治数据滥用与盗版。

这种方法将数据确权的重点从数据本身转移到了NFT上,将数据的流转变为了NFT的流转,将数据交易变为了NFT的交易。这样既保护了数据所有者的权益,也实现了数据在流转和使用过程中的最大价值。

目前,这只是一个简单的想法,欢迎对此感兴趣的朋友进行交流和探讨。这条路也许很长,但我们将持续探索结合区块链技术和数字资产技术的应用场景,为用户带来更好的数字化体验,让数据创造更高的价值。