IOTA 基石 - ISS 签名算法详解

一、概要

    IOTA 消息签名 方案使用的是Winternitz一次签名算法(WOTS),它是一种hash 签名算法,该类算法在几十年前就被提出来了,不过一直没有什么人用,因为它基于该类算法生成的签名实在太长了,并且也没有什么明显的特点,不过因为该类算法被证明可以抵抗量子计算,所以,IOTA 则使用该类算法作为消息 签名的解决方案,不过该类算法存在一个明显的缺陷是每次签名都会暴露一般的私钥,因此,IOTA的账户使用体系会有别于别的经典的区块链账户体系,本章节主要就是详细分析IOTA 的ISS 实现 。

二、详细介绍&解析

    在深入WOTS算法实现的前,我们先先、从最简单的一次签名算法Lamport一次性签名(LOTS)作为入门了解,然后在什么WOTS 的详细实现,最后在看看基于IOTA 是如何使用该算法实现消息验签的;因此,本章节按照以下三小节详细分析。

1)LOTS原理
2)WOTS详细实现
3)WOTS使用

2.1 Lamport原理

    首先我们通过随机算法随机生成一对私钥,每个私钥都包含256个随机数,这里每个随机数都取256bit大小,确实私钥是非常长的,如下

然后我们将这一对私钥中每个随机数都进行hash,得到了公钥对

接着,我们就可以开始签名了,对于文件M,首先计算得到它的hash摘要值,H(M),这里H(M)也是256bit长的,然后我们检查H(M)的每一个bit,对于第n个bit,当其为0时,我们就取私钥串1的第n个数,当其值为1时,我们就取私钥串2的第n个数,比如当文件M的hash为110...10时,情况就如下

将红色块的数字合并就得到了文件M的签名。

    至于该签名的验证也非常简单,我们先计算出文件M的hash,H(M)。后依据同样的算法再从公钥对中取值,将公钥中的hash合并后看是否跟签名相同即可

    观察上面的签名过程,我们不难发现发布签名后实际上我们将私钥的一半公布出去了,哪怕是这样其实这种算法也是很安全的,因为攻击者并不知道另一半的私钥,除非他能破解该hash函数,从公钥推出私钥。

    不过如果你再次使用该私钥进行签名的话,那么又会随机暴露一半的私钥,相当于在之前没暴露的一半里再随机显示一半,这样你暴露的私钥就达到了75%,这样就非常危险了,攻击者已经有能力根据这暴露的私钥信息伪造签名了,所以说hash签名的地址一般都是一次性的,重复使用是不可取的,当然,也不是说就不存在地址可重复使用的hash签名技术,比如基于Merkle树的Merkle OTS方案,该方案使用的公钥是一串公钥对的根hash,事实上每次签名使用的依然是不同的私钥,而且该方案的签名长度更长,公钥对较多的话签名长度可能是Lamport方案的几倍,有兴趣的可以看看这篇文章,Hash based signatures


2.2 WOTS详细实现

2.2.1种子生成

    我们知道IOTA网络是无许可的网络类型,任何人都可以使用它并与之交互。在任何阶段都不需要中央集权管理。根据上述介绍,种子是给定地址的唯一密钥,任何拥有种子的人也拥有与各自IOTA地址相关的所有资金,而任何人都可以随时生成自己的种子和相应的私钥/地址。

而种子和地址仅由字符[A-Z]和数字9组成,长度是固定的81个字符。在IOTA地址中通常还会使用另外9个字符(因此其总长度是90),这是一个校验和。它提供了一种防止在处理IOTA地址时出现错误操作的方法,我们先来深入种子的生成方式:

public class SeedRandomGenerator {

    public static final String TRYTE_ALPHABET = "9ABCDEFGHIJKLMNOPQRSTUVWXYZ";

    /**
     * Generate a new seed.
     * @return Random generated seed.
     **/
    public static String generateNewSeed() {
         //将字母表转换成 char 数组
        char[] chars = TRYTE_ALPHABET.toCharArray();
        StringBuilder builder = new StringBuilder();
        // 使用SecureRandom 随机生成器
        SecureRandom random = new SecureRandom();

        // 随机生成字母表中,81个字符作为seed
        for (int i = 0; i < 81; i++) { 
            char c = chars[random.nextInt(chars.length)];
            builder.append(c);  // 按序组装
        } 
        return builder.toString(); // 返回seed 字符串
    }
}

上述代码比较简单,使用伪随机生成器,随机生成81 指定字符集 中的字符,一共3^243 种组合,虽然是随机生成的,但也可以保证唯一性了。

2.2.2 私钥生成

    先分析私钥生成实现key(int[] inSeed, int index, int security):


    public int[] key(int[] inSeed, int index, int security) throws ArgumentException {

        // 确保security 的范围为[1,3]
        if (!InputValidator.isValidSecurityLevel(security)) {
            throw new ArgumentException(INVALID_SECURITY_LEVEL_INPUT_ERROR);
        }
        
        ...
        //step1,依据index 计算子seed
        int[] seed = subseed(inSeed, index);
        
        ICurl curl = this.getICurlObject(SpongeFactory.Mode.KERL);
        curl.reset();
        curl.absorb(seed, 0, seed.length);
        
        //step2,将hash 后的seed 结果覆盖到seed 中
        curl.squeeze(seed, 0, seed.length);
        curl.reset();
        // absorb subseed
        curl.absorb(seed, 0, seed.length);
        // 构建私钥,长度为security * 243 * 27
        final int[] key = new int[security * HASH_LENGTH * 27];
        
        final int[] buffer = new int[seed.length];
        int offset = 0;
        // step3、依据security反复对对新结果hash ,并填充至每一段
        while (security-- > 0) { 
            for (int i = 0; i < 27; i++) {
                //将上次hash结果在hash,并将结果写入buffer
                curl.squeeze(buffer, 0, seed.length);
                //将hash 结果填充到指定段
                System.arraycopy(buffer, 0, key, offset, HASH_LENGTH);

                offset += HASH_LENGTH;
            }
        }
        return key;
    }

         /*
         * 
         * index 0 = [0,0,0,1,0,-1,0,1,1,0,-1,1,-1,0] // 原seed
         * index 1 = [1,0,0,1,0,-1,0,1,1,0,-1,1,-1,0] // 原seed + 1
         * index 2 = [-1,1,0,1,0,-1,0,1,1,0,-1,1,-1,0] // 原seed + 2
         * index 3 = [0,1,0,1,0,-1,0,1,1,0,-1,1,-1,0] // 原seed +3
         */
    public int[] subseed(int[] inSeed, int index) throws ArgumentException {
        // 防御式判断
        if (index < 0) {
            throw new ArgumentException(INVALID_INDEX_INPUT_ERROR);
        }
        
        int[] seed = inSeed.clone();

        // 执行 index 次 自增一
        for (int i = 0; i < index; i++) {
            for (int j = 0; j < seed.length; j++) {
                if (++seed[j] > 1) {
                    seed[j] = -1;
                } else {
                    break;
                }
            }
        }
        return seed;
    }

    在分析上述获取私钥的源码实现前,我们先来了解以下入参,首先是inSeed,它是长度81的trytes种子 转成 长度为243 trits 的int 数组,种子的概念上面已详细分析; 而index 的作用就是对 inSeed 三进制数组进行作加法;security(默认情况下,iota 统一为2,可取值为1、2、3,级别越高越安全)就是对结果key进行多少次hash运算。而私钥的长度需要由security 来决定,具体值为security * 243 * 27。
    而依据index 计算subseed int[] seed = subseed(inSeed, index)核心就是对原inSeed 执行三进制数组自增index 次,具体的trytes、trits 以及各种转换、运算等,已在《IOTA基石 - 三进制系统之 Trit 和 Tryte》中详细分析,这里就不再深入。
    接着是如何通过seed 来分段求取私钥hash来求取私钥结果key。这里,为了方便描述,我们定义H(M) 为文本内M容的hash摘要,因此,由作出以下定义:

H^1(M) = H(M)
H^2(M) = H(H(M))
H^3(M) = H(H^2(M))
...
H^n(M) = H(H^n-1(M))

这里的H 算法在本章节指的是Kerl,该算法在《IOTA 基石 - Sponge 算法详解》已详细分析,这里就不再深入。
    总结一下私钥key(...)的获取流程:

  • step1,根据入参inSeed 以及 index 获取seed;
  • step2,对step1 中的seed 结果进行顶一次hash,并覆盖原seed;
  • step3,依据security 以及 step 2 中的seed,求私钥结果key,具体的求取流程及结果见下图:
private key
2.2.3地址生成

    传统的以区块链为基础的系统,例如比特币,你的钱包地址是可以被多次重复使用的。但与之相反,IOTA的地址(在进行对外转账时)只能被使用一次。也就是说,一个IOTA地址如果只用来收账,可以使用无限次。但一旦当你使用这个地址向外转账完成后,就不应该再使用改地址了。这是因为,当你对外进行转账的时候(如果你发送的是IOTA),这个特定地址中的部分私有密钥被暴露,进而给了其他人(例如黑客)暴力破解全部密钥,进而最终获得存储在这个地址中的所有IOTA 的可能性。你通过同一个IOTA地址向外转账的次数越多,黑客就越容易暴力破解你的密钥。需要注意的是,获得一个地址的密钥不会暴露你的IOTA种子或是在你的种子(账户)中的其他地址的密钥。上述所描述的缺点是源于hash签名算法所导致的
    总之,对于一个IOTA地址,只要我们不对外进行转账操作(“向外发送”的操作),我们可以使用这个地址进行无限次的安全收账。但一旦你使用这个地址向外转账后,这个地址不应该再被使用了!
    下面,我们通过address 的生成源码来分析一下其实现:


    public static String newAddress(String seed, int security, int index, boolean checksum, ICurl curl) throws ArgumentException {
         ...
        Signing signing = new Signing(curl);
        // 先获取私钥
        final int[] key = signing.key(Converter.trits(seed), index, security);
        //对私钥二次 摘要
        final int[] digests = signing.digests(key);
        // 将再要转成addressTrits
        final int[] addressTrits = signing.address(digests);
        // 将addressTrits 转成 address
        String address = Converter.trytes(addressTrits);
        
        //拼接校验和
        if (checksum) {
            address = Checksum.addChecksum(address);
        }
        // 返回address
        return address;
    }

我们来详细分析上述实现,首先是根据入参求取私钥key,前面一小节已详细分析。获取私钥后,通过signing.digests(key),求取二次摘要:


    public int[] digests(int[] key) throws ArgumentException {
        // 依据key长度 求 security (6561 = 27 * 243)
        int security = (int) Math.floor(key.length / 6561);
        ...
        //二次摘要结果存放
        int[] digests = new int[security * 243];
        //私钥段临时存放
        int[] keyFragment = new int[6561];

        ICurl curl = this.getICurlObject(SpongeFactory.Mode.KERL);
        // 分段处理
        for (int i = 0; i < = security; i++) {
            //
            System.arraycopy(key, i * 6561, keyFragment, 0, 6561);

            for (int j = 0; j < 27; j++) { // 求每一小段(243) 自身的hash
                for (int k = 0; k < 26; k++) { // 对每一段反复hash 自身结果25次,并覆盖原小段
                    curl.reset()
                            .absorb(keyFragment, j * 243, 243)
                            .squeeze(keyFragment, j * 243, 243);
                }
            }
            // 重设状态
            curl.reset();
            // 将keyFragment 的hash 结果输入至digests。
            curl.absorb(keyFragment, 0, keyFragment.length);
            curl.squeeze(digests, i * 243, 243);
        }

        return digests;
    }

上述为私钥的二次摘要实现流程,二次摘要的大小同样依据security 决定,具体为243 * security,即每一段大小为243;根据上述分析,private key是分段,security 为段数, 每一段的大小为243 * 27,而每一段又由小段长度为 243 的int字节数组 组成。换言之,二次摘要 的段数 与 private key 的段数一致;具体的求取过程为,先将privete key 中的每一小段自身hash 26次,然后在将每一段(27 * 243) 作为整体再次输出长度为243 的hash 结果写入二次摘要结果digests 所对应的段数,具体的效果见下:


digests

二次摘要digests 求得后,通过int[] addressTrits = signing.address(digests)求 区块链地址的 三进制区块地址:

    public int[] address(int[] digests) {
        int[] address = new int[HASH_LENGTH];
        ICurl curl = this.getICurlObject(SpongeFactory.Mode.KERL);
        curl.reset()
                .absorb(digests)
                .squeeze(address);
        return address;
    }

上述实现比较简单,无非将digests 作为输入,通过kerl hash 函数,输出长度为243 的三进制hash 值。到这里,三进制 区块地址addressTrits已得到,见下图:


addressTrits

接着,在通过Converter.trytes(addressTrits) 将三进制 转成我们稍微可读的,长度为81 的区块链地址。最后,通过截取address的hash 值的最后九位作为checksum,追加到远address 的尾部,返回给上层应用一个长度为90(81 + 9)的地址,用以下三幅图总结:

summary-address

    依据图[summary-address]总结一下:

  • step1,先根据条件求private key,当然,其长度由security决定,即security 为段数,而一个完整的私钥段又由27 个 243 小段组成;
  • step2,依据private key 求digest,需要注意的是step1 中的 H^N(M) 等于 step2 中的 fragmentN(N 为1、2、3...);
  • step3,最后,在依据step2 中的digest 求 addressTrits。

    到这里,地址生成源码分析完毕。

2.2.4ISS签名

    一般来说,签名流程都是先对需要签名的内容通过hash 函数求取其签名内容的摘要hash(content),然后,在使用指定的签名算法对内容摘要hash(content)进行签名。而IOTA 同样是使用上述流程对其发送的消息进行签名(这里主要指交易内容)。因此,我们下 通过以下代码段来看看具体的签名流程Sign(String messageHash,String seed,int index, int security):


{
    //①求私钥key,key.length =  27 * 243 * security
    int[] key = new Signing(curl).key(Converter.trits(seed), index, security);

    //②规范化hash摘要,normalizedBundleHash.size = 81 = 27 * 3
    int[] normalizedBundleHash = bundle.normalizedBundle(messageHash);

    String[] signeds = new String[security];
    //③ 分段签名,security 为段数
    for (int j =0; j < security; j++) {
      //获取normalizedBundleHash 的第一段内容[0,6561) 
      int[] keyFragment = Arrays.copyOfRange(key, 6561 * j, 6561 * (j + 1));

      //获取normalizedBundleHash 的指定段内容[j * 27,27 * (j + 1) - 1] 
      int[] normalizedBundleFragment = Arrays.copyOfRange(normalizedBundleHash, 27 * j, 27 * (j + 1));

      //执行指定段签名
      int[] signedFragment = new Signing(curl).signatureFragment(normalizedBundleFragment, keyFragment)
      //签名结果写入signeds
      signeds[j]append = Converter.trytes(signedFragment);
    }
}

    我们来解读一下上述代码段:
①首先,依据seed、index以及security 求密钥,这里不再深入;
②对需要签名的内容messageHash 规范化normalizedBundleHash;
③然后依据security 数进行分段签名,并将分段的签名结果写入signeds 中;例如第一段签名signedFragment,则需要依赖私钥key的第一段内容[0,6561) keyFragment 以及 normalizedBundleHash 的第一段内容[0,27)normalizedBundleFragment

    我们先分析规范化normalizedBundle(String bundleHash)干了什么:

  public int[] normalizedBundle(String bundleHash) {
        //防御式判断
        if (bundleHash.length() != 81) {
            throw new RuntimeException("Invalid bundleValidator length: " + bundle.length);
        }
        // 结果存放
        int[] normalizedBundle = new int[81];
        // 具体分成3段处理
        for (int i = 0; i < 3; i++) {

            long sum = 0;
            //将Tryte 转成 10进制值,并写入normalizedBundle对应的位置上
            // 求当前段的10进制总和
            for (int j = 0; j < 27; j++) {
                sum += (normalizedBundle[i * 27 + j] = Converter.value(Converter.trits("" + bundleHash.charAt(i * 27 + j))));
            }
            
            // 对normalizedBundle 求和归0平衡化。
            if (sum >= 0) {
                while (sum-- > 0) {
                    for (int j = 0; j < 27; j++) {
                        if (normalizedBundle[i * 27 + j] > -13) { //确保不超过下限 -13
                            normalizedBundle[i * 27 + j]--;
                            break;
                        }
                    }
                }
            } else {

                while (sum++ < 0) {

                    for (int j = 0; j < 27; j++) {

                        if (normalizedBundle[i * 27 + j] < 13) { // 确保不超过上限13
                            normalizedBundle[i * 27 + j]++;
                            break;
                        }
                    }
                }
            }
        }

        return normalizedBundle;
    }

    我们来详细解读上述代码段,根据IOTA 自身的模型设计,其所有的 领域模型(像Bundle、Transaction、Address...)求取其hash 值后,长度都规定为81 Tryte 的字符串,因此,才有防御式判断if (bundleHash.length() != 81);然后,将bundleHash 分三段处理,每段长度为27【[0,27),[27,54),[54,81)】(这里与security[1,2,3]一一对应 )。
    而每段处理的内容如下,首先,将bundleHash(长度81) 转成 10进制int 数组,并将转换结果一一对应写入normalizedBundle(长度81)中。另外,bundleHash是由Tryte 组成,而Tryte 转成10进制的数字的范围为[-13, 13],因此,normalizedBundle 数组中的每一个数的数值范围为[-13, 13],然后在求normalizedBundle当前段的10进制数值总和sum。
    最后,在对normalizedBundle中的每一段做求和归0平衡化,该处理结果后使得normalizedBundle 中的每一段都有一个特性,就是每一段的数值总和为0当该。例如当sum>0时,循环将当前段的第一个10进制数减一,直到修改后sum为0,如果当前段的第一个10进制被减到最小值-13,则从当前段第二个10进制数减继续,一直往后直到sum为0,反之当sum小于0时亦然。
    而归0平衡的目的是化修正hash次数的偏差,这里我认为这种修正基本影响不大,后续的分析读者就会清晰为什么。

    normalizedBundle分析完后,我们接着看看具体的签名实现signatureFragment(normalizedBundleFragment, keyFragment):

    public int[] signatureFragment(int[] normalizedBundleFragment, int[] keyFragment) {
        // 27 * 243,一共27小段
        int[] signatureFragment = keyFragment.clone();
        
        //对27 小段反复hash自身 
        for (int i = 0; i < 27; i++) {
            // 每段hash的次数13 - normalizedBundleFragment[i]
            for (int j = 0; j < 13 - normalizedBundleFragment[i]; j++) {
                curl.reset()
                        .absorb(signatureFragment, i * HASH_LENGTH, HASH_LENGTH)
                        .squeeze(signatureFragment, i * HASH_LENGTH, HASH_LENGTH);
            }
        }

        return signatureFragment;
    }

    上述代码段是不是比较眼熟,细心的读者会发现,它与 【2.2.3地址生成】中,通过private key 获取二次摘要的实现基本一致,只不过二次摘要获取实现过程中,对相应私钥小段反复hash 26 次,而签名时,则反复hash (13 - normalizedBundleFragment[i] )次,从而得到具体签名段signatureFragment。具体见下:

compare

    到这里,签名分析完毕。

2.2.5ISS验签

    分析完签名后,我们继续分析验签流程Verify(...):

boolean Verify(String messageHash, String[] signatureFragments,String address) {
        int[][] normalizedBundleFragments = new int[3][27];
        
        int[] normalizedBundleHash = normalizedBundle(messageHash);

        // 将normalizedBundleHash 分成3段,与签名时一致每段大小为27
        for (int i = 0; i < 3; i++) {
            normalizedBundleFragments[i] = Arrays.copyOfRange(normalizedBundleHash, i * 27, (i + 1) * 27);
        }
        
        // 签名的段数与security一致
        int security = signatureFragments.length;
        //digests 用于转成地址
        int[] digests = new int[security * 243];
      
       // 通过 签名内容求digest
       for (int i = 0; i < security; i++) {
            //求摘要
            int[] digestBuffer = digest(normalizedBundleFragments[i], Converter.trits(signatureFragments[i]));

            System.arraycopy(digestBuffer, 0, digests, i * 243, 243);
        } // end for

        // 摘要转地址
        String signatureAddress = Converter.trytes(address(digests))
    
    //比较验签过程所求地址signatureAddress与 实际地址address
    // 若相等则说明验签成功,消息没有被篡改
    return address.equals(signatureAddress);
}

    //对验签段 继续求剩余的hash
    public int[] digest(int[] normalizedBundleFragment, int[] signatureFragment) {
        curl.reset();
        ICurl jCurl = this.getICurlObject(SpongeFactory.Mode.KERL);
        int[] buffer = new int[243];

        for (int i = 0; i < 27; i++) {
            buffer = Arrays.copyOfRange(signatureFragment, i * HASH_LENGTH, (i + 1) * 243);
            //
            for (int j = normalizedBundleFragment[i] + 13; j-- > 0; ) {
                jCurl.reset();
                jCurl.absorb(buffer);
                jCurl.squeeze(buffer);
            }
            curl.absorb(buffer);
        }
        curl.squeeze(buffer);

        return buffer;
    }
    
    // 通过摘要求地址
    public int[] address(int[] digests) {
        int[] address = new int[HASH_LENGTH];
        ICurl curl = this.getICurlObject(SpongeFactory.Mode.KERL);
        curl.reset()
                .absorb(digests)
                .squeeze(address);
        return address;
    }

    上述为验签流程的具体实现,我们来详细分析。
    
    首先,对签名内容messageHash先求其normalizedBundle(messageHash),如果messageHash与签名前一致,即消息在传输过程中没有被修改,其normalizedBundleHash 与签名时一致,对于normalizedBundle(...)的实现,上面已分析,这里不再深入;在将normalizedBundleHash 分成三段,写入normalizedBundleFragments中。
    接着,通过for (int i = 0; i < security; i++)循环来来逐段处理验签段signatureFragment,处理方式为通过digest(normalizedBundleFragments[i], Converter.trits(signatureFragment)),继续对签名段中signatureFragment的每一小段(27 * 243, 每小段为243)进行反复自身hash指定次数。我们在仔细比较一下【签名阶段中的degist(...) 】以及 【验签阶段中的degist(...) 】,唯一的区别是,前者的 每一小段 的hash 次数为13 - normalizedBundleFragment[i], 后者是在前者基础上在继续hash 13 + normalizedBundleFragment[i] + 1,即一共27次,这样一来,验签过程degist完成后,其效果不就等价于【2.2.3地址生成】中,通过private key 获取二次摘要的流程,如果验签内容保持不变的情况下,通过验签过程后的degists,在通过Converter.trytes(address(digests)) 转换成signatureAddress,实际上会与消息的发起方address一致,从而达到验签目的。具体效果见下:

    因此,总结一下,将 【地址生成】流程在二次摘要阶段拆分成两段,上部分签名,下部分为验签。而【签名内容】实际就是拆分二次摘要过程中,对私钥段hash 次数的核心。到这里,验签分析完毕。


2.3 WOTS使用

2.3.1 WOTS私钥段暴露

    在【LOTS原理】中由详细分析到,LOTS 在每次签名过程中,都有50% 的概率随机暴露接近一半的私钥段,从而导致其私钥不能重用。而WOTS 的签名又是如何暴露私钥段的?细心的读者会发现,签名过程中signatureFragment(normalizedBundleFragment, keyFragment)实现流程中,会对私钥段keyFragment进行指定次数13 - normalizedBundleFragment[i] 的反复hash,而normalizedBundleFragment[i]则是签名内容的tryte hash 摘要 10进制平衡转换,当normalizedBundleFragment[i]为13时(对应tryte 字母M),即13 - normalizedBundleFragment[i] 为0,则直接暴露了当前私钥段,但这都不是核心问题,因为私钥段够长,哪怕暴露了好些私钥段,也基本不会影响安全,唯独的一个核心缺陷就是,如果normalized bundlehash第一个Tryte对应的值即为13(对应normalizedBundleFragment[0] 为13),这表示该地址第一块的私钥被签名暴露了,在根据私钥的生成规则,我们就可以反推出完整私钥。:

    我们来看看IOTA 是如何解决这个问题的?核心在bundle hash 的生成:

    public void finalize(ICurl customCurl) {
        ICurl curl;
        int[] normalizedBundleValue;
        int[] hash = new int[243];
        int[] obsoleteTagTrits = new int[81];
        String hashInTrytes;
        boolean valid = true;
        curl = customCurl == null ? SpongeFactory.create(SpongeFactory.Mode.KERL) : customCurl;
        do {
          // 读取bundle中的内容,并转为trits
          for (int i = 0; i < this.getTransactions().size(); i++) {
              int[] t = Converter.trits(... + this.getTransactions().get(i).getObsoleteTag() + ...);
          }

          curl.absorb(t, 0, t.length);
          // 求取bunlde hash
          curl.squeeze(hash, 0, hash.length);
          ...
          hashInTrytes = Converter.trytes(hash);
          // 转成 normalizedBundle
          normalizedBundleValue = normalizedBundle(hashInTrytes);

          boolean foundValue = false;
            for (int aNormalizedBundleValue : normalizedBundleValue) {
                if (aNormalizedBundleValue == 13) {
                    //代码走到这里,说明normalizedBundleValue存在值为13的10进制数值,需要自增第一笔交易的obsoleteTag,并重新计算bundle hash
                    foundValue = true;
                    obsoleteTagTrits = Converter.trits(this.getTransactions().get(0).getObsoleteTag());
                     //obsoleteTagTrits自增一
                    Converter.increment(obsoleteTagTrits, 81) ; 
               
                    //重新设置新的ObsoleteTag   
                    this.getTransactions().get(0).setObsoleteTag(Converter.trytes(obsoleteTagTrits));
                }
            }
          
          valid = !foundValue;

        } while (!valid);//当valid 为false,说明normalizedBundle 不存在值为13的10进制数值,可以跳出循环,获取bundle hash

        ...
    }

为了修复这一漏洞,求bundle hash 时,会同时计算bundle hash 的normalizedBundleHash,如果normalizedBundleHash中包含M的话,会将index为0的交易中obsoleteTag字段加一,然后再算一次bundlehash,循环往复直到normalized bundlehash中不包含M为止,这样一来,确保第一次计算签名的请求不会暴露任何私钥段。

    出于hash 签名的特性,重用地址是非常危险的,不过在IOTA系统中,还是有存在地址重用的情况,协调者所使用的签名方案就是可重用地址的Merkle OTS,因为它确实存在这样的需求,得去批准大量的交易以稳定网络,代价是更长的签名,目前社区中也在探讨可重用地址机制的可行性。
    此外,IOTA的快照机制也会导致地址重用,在IOTA中为了节省存储空间,会定期清空Tangle上的交易,只在记录上保留有余额的地址,因为钱包在由其种子派生出的私钥中按index从上往下进行搜索时碰到余额为0的就会停止,所以在每次快照后有必要将index排在前面的余额为空的地址附加到Tangle上,否则就可能会出现地址的重用,这些会在后面的源码分析在详细讲解。
    到这里,ISS 签名全文分析完毕。