一、概要
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,具体的求取流程及结果见下图:
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 求得后,通过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已得到,见下图:
接着,在通过Converter.trytes(addressTrits)
将三进制 转成我们稍微可读的,长度为81 的区块链地址。最后,通过截取address的hash 值的最后九位作为checksum,追加到远address 的尾部,返回给上层应用一个长度为90(81 + 9)的地址,用以下三幅图总结:
依据图[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。具体见下:
到这里,签名分析完毕。
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 签名全文分析完毕。