NEO从源码分析看nep2与nep6

643 阅读11分钟

0x00 前言

混社区的时候(QQ群)总是听到大佬们聊到nep,好奇心驱使下就去neo官网找资料,然鹅,什么都没找到。后来就请教大佬,才知道nep是neo一系列提案,文档并不在neo官网,在这里。但是很奇怪的是我到目前为止只听说到了nep2,nep5和nep6,其余的几个提案似乎没什么人讲,以后有机会我再仔细了解下。nep2提案是一套加密私钥的算法,nep5提案是发布token相关的,nep6则是定义了标准化的neo钱包数据结构。由于我现在了解的最详尽的是nep2和nep6(好几个sdk源码都撸了一遍),而且nep2和nep6也是相辅相成密不可分,所以这里我就先主要从源码角度分析下nep2和nep6. 注: 本文行文逻辑 新账户 => nep2加解密 => 添加到nep6钱包

0x01 私钥

和几乎所有的加密货币一样,NEO的账户也是用了基于椭圆曲线的公私钥生成算法,在NEO的账户体系中,公钥由私钥计算而来,地址又由公钥计算而来,可以说只要掌握了私钥,就完全掌握了这个账户。数学原理请移步这里下载密码学书籍学习。 NEO的私钥是随机生成的长度为32的字节数组:

源码位置:neo/Wallets/Wallet.cs/CreateAccount()

 byte[] privateKey = new byte[32];
 using (RandomNumberGenerator rng = RandomNumberGenerator.Create())
 {
          rng.GetBytes(privateKey);
  }

由于各个节点新账户的生成完全在本地进行,所以必须保证随机数生成器完全随机也就是安全随机才能真正确保账户的唯一性以及安全性,这里我研究了不同平台采取的安全随机数策略,首先就是neo内核C#版本采用的RandomNumberGenerator类,这个随机数生成算法以当前系统runtime环境参数作为熵源产生随机数,虽然执行效率比System.Random要慢上两个数量级,但是产生的结果却是安全的。

这里我还想说一下我在开发NEO钱包小程序的时候遇到的问题,那就是微信小程序并不提供安全的随机数生成算法,同时也不支持node内置的crypto,这让我纠结了很久,因为没有安全的随机数生成算法,那么这个钱包几乎就是不可用的。我曾想过:

  • 用户当前的经纬度,加速度,海拔
  • 用户拍照并对照片进行哈希

等方法来作为熵源,但是第一种密钥空间太小,第二种没办法实现。后来我发现在每次获取用户授权数据的时候,会收到一段加密的字符串。我研究了下这个加密算法,主要是AES-128-CBC,而且每次解密初始向量都是不同的,长度也完全满足需求,因此这段加密字符串可以认为是安全随机。

源码位置:NewEconoLab/NeoWalletForWeChat/blob/master/src/utils/random.js

export async function getSecureRandom(len) {
  wepy.showLoading({ title: '获取随机数种子' });
  let random = ''
  const code = await this.getLoginCode();
  const userinfo = await this.getUserInfo();
  console.log(code)
  random = SHA256(code + random).toString()
  random = SHA256(userinfo.signature + random).toString()
  random = SHA256(userinfo.encryptedData + random).toString()
  random = SHA256(userinfo.iv + random).toString()
  console.log(random)
  wepy.hideLoading();
  return random.slice(0, len)
}

0x02 公钥

NEO从私钥计算公钥的算法和比特币是一样的,这部分讲的最好的当然是《Mastering BitCoin》中的第四章(下载连接),其中不仅详尽生动的讲解了比特币公私钥生成原理,而且辅助了大量的插图便于理解。比特币在生成公钥的时候选取的曲线是secp256k1曲线,而NEO选取的则是secp256r1。在StackOverflow上也有关于这两个曲线哪个更安全的讨论,详情点击连接,但是这个不在我的讨论范围。下面是secp256r1定义:

源码位置:neo/Cryptography/ECC/ECCurve.cs

        /// <summary>
        /// 曲线secp256r1
        /// </summary>
        public static readonly ECCurve Secp256r1 = new ECCurve
        (
            BigInteger.Parse("00FFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFF", NumberStyles.AllowHexSpecifier),
            BigInteger.Parse("00FFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFC", NumberStyles.AllowHexSpecifier),
            BigInteger.Parse("005AC635D8AA3A93E7B3EBBD55769886BC651D06B0CC53B0F63BCE3C3E27D2604B", NumberStyles.AllowHexSpecifier),
            BigInteger.Parse("00FFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551", NumberStyles.AllowHexSpecifier),
            ("04" + "6B17D1F2E12C4247F8BCE6E563A440F277037D812DEB33A0F4A13945D898C296" + "4FE342E2FE1A7F9B8EE7EB4A7C0F9E162BCE33576B315ECECBB6406837BF51F5").HexToBytes()
        );

以上源码是NEO中secp256r1标准椭圆曲线的定义,哪怕不从密码学角度来看,就这参数的长度就给人一种想狗带的感觉。 生成公钥的时候,私钥需要乘上一个预先定义在曲线上的基点,获得的结果就是公钥。这个基点被称为G,所有的NEO节点的G都是相同的,也就是Secp256r1定义中最后那个特别长的字节数组。 《Mastering BitCoin》中的介绍如下:

_K = k * G

where k is the private key, G is the generator point, and K is the resulting public key, a point on the curve. Because the generator point is always the same for all bitcoin users, a private key k multiplied with G will always result in the same public key K. The relationship between k and K is fixed, but can only be calculated in one direction, from k to K. That’s why a bitcoin address (derived from K) can be shared with anyone and does not reveal the user’s private key (k)._

在NEO core中,这部分代码在KeyPair类中,但是由于计算部分主要是关于ECC的,所以我就不贴了。

0x03 地址

前文已经说过neo的地址是由公钥计算来的,但是其实还并不准确,这中间还是有很复杂的过程的。首先根据私钥生成账户的代码在NEP6Wallet类中:

源码位置:neo/Implementations/Wallets/NEP6/NEP6Wallet.cs

     public override WalletAccount CreateAccount(byte[] privateKey)
        {
            KeyPair key = new KeyPair(privateKey);  //根据私钥生成公私钥对
            NEP6Contract contract = new NEP6Contract   //生成合约
            {
                Script = Contract.CreateSignatureRedeemScript(key.PublicKey),  //合约脚本
                ParameterList = new[] { ContractParameterType.Signature },
                ParameterNames = new[] { "signature" },
                Deployed = false   //不需要部署的鉴权合约
            };
            NEP6Account account = new NEP6Account(this, contract.ScriptHash, key, password)
            {
                Contract = contract
            };
            AddAccount(account, false);
            return account;
        }

从源码中可以看出,在生成新账户时,会根据公钥创建一个鉴权合约,创建合约的代码在Contract类的CreateSignatureRedeemScript方法中:

源码位置:neo/SmartContract/Contract.cs

      public static byte[] CreateSignatureRedeemScript(ECPoint publicKey)
        {
            using (ScriptBuilder sb = new ScriptBuilder())
            {
                sb.EmitPush(publicKey.EncodePoint(true));//push公钥编码后的字节数组
                sb.Emit(OpCode.CHECKSIG);
                return sb.ToArray();
            }
        }

这个方法会返回合约的脚本,地址就是根据这个脚本的哈希值得来的。在生成地址的时候,会传入这个合约脚本的哈希值:

源码位置:neo/Wallets/Wallet.cs

      public static string ToAddress(UInt160 scriptHash)
        {
            byte[] data = new byte[21];
            data[0] = Settings.Default.AddressVersion;
            Buffer.BlockCopy(scriptHash.ToArray(), 0, data, 1, 20);
            return data.Base58CheckEncode();
        }

在生成地址的时候,首先申请21字节缓冲区,缓冲区首字节设置为地址版本校验位,后20字节copy自合约哈希的前20个字节,然后对这个缓冲区进行base58加密得到的值就是我们的地址。 整体流程和BieCoin对比如下:

NEO地址流程 BitCoin地址流程

第一张比较丑的流程图是我画的NEO地址生成过程,第二张是从《Mastering BitCoin》书中截取的比特币地址生成流程,通过对比可以看出,除了NEO的地址是根据合约脚本哈希值而BItCoin是Sha256+RIPEMD160之后的摘要生成之外,两者的地址计算过程几乎一摸一样。

0x04 nep2

上文中已经从私钥到地址的整个流程都分析完了,如果是使用NEO账户的话,到上一小节,已经完全够了。从本小节往后讲的都是关于账户安全和账户管理的部分。 nep2是为了确保NEO账户私钥安全而提出的私钥加密提案,在提案里详细讲解了加密和解密的参数以及流程规范。 nep2分为两个部分,一个是加密,另一个是解密。加密的代码如下:

源码位置:neoWallets/KeyPair.cs

         public string Export(string passphrase, int N = 16384, int r = 8, int p = 8)
        {
            using (Decrypt())
            {
                //获取地址合约脚本哈希
                UInt160 script_hash = Contract.CreateSignatureRedeemScript(PublicKey).ToScriptHash();
                //获取地址
                string address = Wallet.ToAddress(script_hash);
                //获取地址摘要前四字节
                byte[] addresshash = Encoding.ASCII.GetBytes(address).Sha256().Sha256().Take(4).ToArray();
                //计算scrypt key
                byte[] derivedkey = SCrypt.DeriveKey(Encoding.UTF8.GetBytes(passphrase), addresshash, N, r, p, 64);
                byte[] derivedhalf1 = derivedkey.Take(32).ToArray();
                byte[] derivedhalf2 = derivedkey.Skip(32).ToArray();
                //aes加密
                byte[] encryptedkey = XOR(PrivateKey, derivedhalf1).AES256Encrypt(derivedhalf2);
                byte[] buffer = new byte[39];
                //校验位
                buffer[0] = 0x01;
                buffer[1] = 0x42;
                buffer[2] = 0xe0;
                //将地址摘要前四字节写入缓存
                Buffer.BlockCopy(addresshash, 0, buffer, 3, addresshash.Length);
                //密文写入缓存
                Buffer.BlockCopy(encryptedkey, 0, buffer, 7, encryptedkey.Length);
                //base58加密
                return buffer.Base58CheckEncode(); 
            }
        }

这个算法就是完全依据nep2提案的标准进行实现的,需要说明的是在最后的数据格式里,前三字节是校验位,之后四个字节是地址的哈希值,最后是密钥的密文,之所以构造这样的数据结构,是因为在解密的时候还需要从中提取地址哈希用于获取scrypt key。加密流程图如下:

nep2加密流程

而解密的过程则是和加密相反:

源码位置:neo/Wallets/Wallet.cs

  public static byte[] GetPrivateKeyFromNEP2(string nep2, string passphrase, int N = 16384, int r = 8, int p = 8)
        {
            if (nep2 == null) throw new ArgumentNullException(nameof(nep2));
            if (passphrase == null) throw new ArgumentNullException(nameof(passphrase));
            //base58解密
            byte[] data = nep2.Base58CheckDecode();
            //格式校验
            if (data.Length != 39 || data[0] != 0x01 || data[1] != 0x42 || data[2] != 0xe0)
                throw new FormatException();
            byte[] addresshash = new byte[4];
            //读取地址哈希
            Buffer.BlockCopy(data, 3, addresshash, 0, 4);
            //计算scrypt key 这里结果和加密的 scrypt key需要相同
            byte[] derivedkey = SCrypt.DeriveKey(Encoding.UTF8.GetBytes(passphrase), addresshash, N, r, p, 64);
            byte[] derivedhalf1 = derivedkey.Take(32).ToArray();
            byte[] derivedhalf2 = derivedkey.Skip(32).ToArray();
            byte[] encryptedkey = new byte[32];
            Buffer.BlockCopy(data, 7, encryptedkey, 0, 32);
            //aes解密获取私钥
            byte[] prikey = XOR(encryptedkey.AES256Decrypt(derivedhalf2), derivedhalf1);
            //计算公钥
            Cryptography.ECC.ECPoint pubkey = Cryptography.ECC.ECCurve.Secp256r1.G * prikey;
            //获取账户合约脚本哈希
            UInt160 script_hash = Contract.CreateSignatureRedeemScript(pubkey).ToScriptHash();
            //计算地址
            string address = ToAddress(script_hash);
            //验证解密结果
            if (!Encoding.ASCII.GetBytes(address).Sha256().Sha256().Take(4).SequenceEqual(addresshash))
                throw new FormatException();
            return prikey;
        }

解密所使用的scrypt参数需要和加密过程相同,不然无法得出相同的scrypt key,也就无法解出privateKey。下面是nep2解密流程:

nep2解密流程图

0x05 nep6

nep6是NEO为了给不同的钱包应用提供统一的数据格式标准而制定的,所有实现了nep6协议的钱包应用,其钱包数据都是可以通用的。 新建钱包的时候需要指定新钱包的路径以及名称:

源码位置:neo/Implementations/Wallets/NEP6/NEP6Wallet.cs/NEP6Wallet(string path, string name = null)

                this.name = name;
                this.version = Version.Parse("1.0");
                this.Scrypt = ScryptParameters.Default;
                this.accounts = new Dictionary<UInt160, NEP6Account>();
                this.extra = JObject.Null;

同时,每个NEP6钱包都可以保存多个NEP6Account对象,也就是说每个钱包里可以有多个地址账户。 NEP6的账户类里并不存储私钥,而是存储的加密后的nep2key,用户在导入nep6钱包后,如果想获取到账户私钥信息,就需要用户手动输入对应账号的passphrase才可以。这里需要注意的是,由于每个钱包只有一份Scrypt参数,所以在nep6钱包里的账户是不能指定不同的scrypt参数的。 nep6的钱包保存成文件的时候是以json的格式保存的,账户转json的代码如下:

源码位置:neo/Implementations/Wallets/NEP6/NEP6Account.cs

      public JObject ToJson()
        {
            JObject account = new JObject();
            account["address"] = Wallet.ToAddress(ScriptHash);//地址
            account["label"] = Label; //账户标签
            account["isDefault"] = IsDefault;
            account["lock"] = Lock; 
            account["key"] = nep2key;//nep2key
            account["contract"] = ((NEP6Contract)Contract)?.ToJson();//账户合约
            account["extra"] = Extra; //补充信息
            return account;
        }

nep6钱包转Json代码如下:

源码位置:neo/Implementations/Wallets/NEP6/NEP6Wallet.cs

     public void Save()
        {
            JObject wallet = new JObject();
            wallet["name"] = name; //钱包名
            wallet["version"] = version.ToString(); //钱包版本
            wallet["scrypt"] = Scrypt.ToJson(); //scrypt加密参数
            wallet["accounts"] = new JArray(accounts.Values.Select(p => p.ToJson()));//账户转json
            wallet["extra"] = extra;
            File.WriteAllText(path, wallet.ToString());
        }

以上就是NEO创建账户及钱包管理账户的全部内容,由于本人技术有限,难免疏漏错误之处,万望多多指教。 另外,本人开发的NEO微信钱包小程序已经上线微信小程序商城,大家可以搜索 “NEO”进入钱包试用。小程序基于NEL ThinSDK-ts进行开发,源码发布于NEL github仓库, 地址是 :

github.com/NewEconoLab…

小程序钱包主要功能基本完成并测试通过,但是尤待优化补充欢迎各位提交代码或者提出宝贵意见。如果您需要GAS或者NEO进行小程序的测试,可以发邮件到 jinghui@wayne.edu 联系我,我可以给您转一些测试网的GAS。

最后,本文发布之后我会着手NEP协议的汉化,希望感兴趣的朋友帮助我一起完成这个任务:github.com/Liaojinghui…

进技术群交流:795681763