一、概要
Sponge是IOTA自身抽象的 ,目前有两种实现,一个是kerl,另外一个curl。而kerl 是基于hash 散列函数Keccak 封装。而curl 自是自己法明的hash 散列函数。一方面,像区块链地址生成,交易签名的实现等,都需要依赖于此hash散列函数;另一方面,基于该类hash 签名具有防量子算法攻击的作用,导致iota 目前的 账户体系的特殊(区块链地址不可重用,后面在详细分析),所以,有必要深入该算法实现。
二、详细介绍&解析
IOTA 使用了三进制作为编码方式,所以使用了 SHA-3 发明者设计的三进制哈希算法 Curl;但是波士顿和 MIT 大学在 2017 年 9 月发布了一篇名为IOTA Vulnerability Report: Cryptanalysis of the Curl Hash Function Enabling Practical Signature Forgery Attacks on the IOTA Cryptocurrency的报告,指出IOTA 的签名实现中,使用 Curl 三进制哈希算法,会存在hash 碰撞。
作为区块链项目底层的重要设施的哈希算法,Curl 并没有经历过较长时间的研究和审查就这样使用在了公链中,这种做法并不是特别的明智,在出现安全隐患之后,IOTA 团队切换至更加成熟的 Keccak 哈希算法,当然,curl 本身作为hash 摘要还是没问题的。这里额外像补充密码学中的一条『黄金法则』- 永远不要自己发明加密算法。
The golden rule of cryptographic systems is “don’t roll your own crypto.”
因此,本章节打算用4小节来详细分析
1)Sponge 算法蔟设计
2)Keccak 算法详解
3)Kerl 算法详解
4)Curl 算法详解
2.1 Sponge算法蔟设计
上面为IOTA 设计的海绵函数类图,主要实现有Kerl 以及 Curl。然后下面为这俩类函数在IOTA 中的用途:
根据[Sponge 用途]总结,我们可以知道,区块链地址生成、签名生成、验签 以及 bundle hash 生成都是使用Kerl hash 算法,另外,iota 地址是81t'rytes组成,然后在用9个trytes 作为校验和;这里需要注意的是,在iota 中的bundle模型 才对应我们日常理解的transaction,而iota 中的transaction 模型 则对应比特币中input/output,这个在后续会详细分析。,
另外Curl-P-N 中的N 则表明对一个输入运算相同的hash 转换函数N 次;transaction 模型的hash 生成 以及 bundle 中的微型 pow 使用Curl-P-81, Milestone 校验则使用Curl-P-27。
2.2 Keccak 算法详解
在介绍Sponge 前,我们先来介绍Keccak,Keccak使用的是海绵函数:
它使用有限的状态,接收任何长度的输入字节数组,然后按照指定长度字节数组输出。
海绵函数是由三个部分组成:
- 一个内存状态 S,包含 b个 bit
- 一个能置换或者转换内存状态,固定大小的转换函数 f
- 一个填充函数(padding function)P
内存状态会分成两个区块,R(大小为 r bit)与 C(大小为b-r bit)。这里的参数 r又叫做转换率(bitrate),而 c叫做容量(capacity)。填充函数会在输入里面增加足够的长度,让输入的比特流长度变成 r的整数倍。因此填充过后的输入可以被切成长度为 r的数个分段。然后,海绵函数运作如下:
- S先初始化为零
- 输入经过填充函数处理
- 填充后输入的前面 r个bit会与R进行XOR运算
- S经过函数 f转换成 f(S)
- 如果填充后输入还有剩余,下一 r bit的分段与 R进行XOR运算
- S转换成f(S)
- …
这过程一直重复到所有的输入都用完为止(在海棉的譬喻里面,被函数“吸收[absorb]”了)。
现在海绵函数可以依照如下的过程输出(“挤出”[squeeze]内容):
- 此时 R里面的数据是输出的前面 r个 bit
- 如果需要更多输出,先把 S转换成f(S)
- 此时R里面的数据是输出的下面 r个bit
- …
这过程会重复到满足输出所需要的长度为止。这里值得注意的地方是,输入绝对不会与C这部分的存储器作XOR运算,而且 C这一部分存储器也不会直接被输出。 C这一部分的存储器仅仅只和转换函数f相关。在散列里面,防止撞击攻击(Collision attack)或者原像攻击(preimage attack)是依靠 C这段存储器作到的。一般实做上 C的大小会是所希望防止等级的两倍。
开源hash算法实现org.bouncycastle中,其海绵函数实现则对应于 KeccakDigest(implement of Keccak)。这里补充一下,sha3 的核心是进一步封装了KeccakDigest 中 absorb 以及 squeeze。我们通过一段代码来了解一下:
// 接受任意长度的字节数组内容输入
// 返回长度为 256字节的散列结果
public static byte[] sha3_256(byte[] bytes) {
Digest digest256 = new SHA3Digest(256);
//absorb
digest256.update(bytes, 0, bytes.length);
//结果存放,大小为256
byte[] result = new byte[digest256.getDigestSize()];
// squeeze,将散列结果写入result
digest256.doFinal(result, 0);
return result;
}
//SHA3Digest 继承于 Keccak ,对hash 结果的大小进行裁剪封装
public class SHA3Digest extends KeccakDigest {
public SHA3Digest(int bitLength) {
//KeccakDigest(int bitLength)
super(checkBitLength(bitLength));
}
}
上述代码段是sha3_256 的封装,入参是任意长度的字节数组,返回长度为 256/8 = 32字节数组的散列结果,具体的做法是通过海绵函数Keccak所提供absorb-squeeze 来实现,当然,根据上述absorb-squeeze 可以反复调用的,具体看使用者如何来架构自身的加密体系。另外,本章节不会升入Keccak 中的absorb-squeeze的代码详细。
而IOTA 中的Kerl hash 算法,同样也是封装了KeccakDigest(384),下面详细分析。
2.3 Kerl 算法详解
经过上面的分析,我们了解了Keccak 算法的实现原理。一方面,我们在《IOTA基石 - 三进制系统之 Trit 和 Tryte》章节中已详细分析iota 是基于三级制构建的,而KeccakDigest 接受的输入是byte 数组,返回也是字节数组;因此,Kerl 算法核心是在将输入的三进制数组转成 byte 数组作为输入,经过Keccak的absorb-squeeze 后,把输出结果转为再次转为三进制,详细先看absorb:
public void absorb(final byte[] trits, final int offset, final int length) {
if (length % 243 != 0) {
throw new RuntimeException("Illegal length: " + length);
}
// HASH_LENGTH = 243
// 分段处理
for (int pos = offset; pos < offset + length; pos += HASH_LENGTH) {
//BYTE_HASH_LENGTH = 384/8
//convert to bytes && update
byte[] state = new byte[BYTE_HASH_LENGTH];
// 最后一位设置为0?
trits[pos + HASH_LENGTH - 1] = 0;
bytesFromBigInt(bigIntFromTrits(trits, pos, HASH_LENGTH), state);
// absorb
keccak.update(state);
}
}
上述代码段为封装keccak的absorb的实现,核心是先通过bigIntFromTrits(...)将传进的三进制字节转bigInt,然后在通过bytesFromBigInt(...)将biguInt 转成正常的字节数组,最后在使用keccak原生的absorb 实现。当然,这里是以243大小 对trits进行分段absorb,基于iota 定义hash 大小为243。对于具体的bigIntFromTrits(...) 以及 bytesFromBigInt(...) 已在《IOTA 三进制系统之 Trit 和 Tryte》中详细分析,这里不再深入。我们接着分析squeeze(...):
public void squeeze(final byte[] trits, final int offset, final int length) {
if (length % 243 != 0) {
throw new IllegalArgumentException("Illegal length: " + length);
}
try {
for (int pos = offset; pos < offset + length; pos += HASH_LENGTH) {
byte[] state = new byte[BYTE_HASH_LENGTH];
//squeeze
keccak.digest(state, 0, BYTE_HASH_LENGTH);
//convert into trits
BigInteger value = new BigInteger(state);
tritsFromBigInt(value, trits, pos, Sponge.HASH_LENGTH);
trits[pos + HASH_LENGTH - 1] = 0;
//calculate hash again
for (int i = state.length; i-- > 0; ) {
state[i] = (byte) (state[i] ^ 0xFF);
}
keccak.update(state);
}
} catch (DigestException e) {
e.printStackTrace(System.err);
throw new RuntimeException(e);
}
}
基于absorb 是分段处理,同理,squeeze也需要分段处理,通过keccak原生的squeeze(这里对应方法keccak.digest(state, 0, BYTE_HASH_LENGTH)
)对相应的243 分段获取hash摘要结果,结果是byte 字节数组,存放于临时字节数组遍历state
中,然后在通过tritsFromBigInt(...)将state 转为 trits。
到这里,kerl 实现分析完毕。
2.4 Curl 算法详解
刚刚在上文有提到,Curl hash 算法用途主要有两类,一类是生成transaction 模型的唯一hash,另外一类主要是用于pow 运行;而Curl 的pow 类运算实现已有相应PearlDiver
(可理解为Curl中相应的pow 实现被移到PearlDiver
中)类实现,因此,本章节不会分析pow 相应hash 运算,会在后续的pow 实现中在详细分析,这里主要是分析生成唯一hash的转换实现。当然,这里也只是分析其工程实现,至于具体的数学论证本章节不会涉及。
因为Curl 也是海绵函数,因此我们只需分析absorb-squeeze 即可:
//------------------------absorb
private static final int STATE_LENGTH = 3 * HASH_LENGTH; // HASH_LENGTH = 243
private final byte[] state;
// 构造函数,27/81
protected Curl(SpongeFactory.Mode mode) {
switch(mode) {
case CURLP27: {
numberOfRounds = NUMBER_OF_ROUNDSP27;
} break;
case CURLP81: {
numberOfRounds = NUMBER_OF_ROUNDSP81;
} break;
default: throw new NoSuchElementException("Only Curl-P-27 and Curl-P-81 are supported.");
}
state = new byte[STATE_LENGTH];
//...
}
public JCurl absorb(final int[] trits) {
return absorb(trits, 0, trits.length);
}
public JCurl absorb(final int[] trits, int offset, int length) {
do {
System.arraycopy(trits, offset, state, 0, length < HASH_LENGTH ? length : HASH_LENGTH);
transform();
offset += HASH_LENGTH;
} while ((length -= HASH_LENGTH) > 0);
return this;
}
public int[] squeeze(final int[] trits) {
return squeeze(trits, 0, trits.length);
}
//-------------------------- sqeeze
public int[] squeeze(final int[] trits, int offset, int length) {
do {
System.arraycopy(state, 0, trits, offset, length < HASH_LENGTH ? length : HASH_LENGTH);
transform();
offset += HASH_LENGTH;
} while ((length -= HASH_LENGTH) > 0);
return state;
}
从上述实现中,我们可以看到absorb以及 squeeze 实现一致,首先都是 分段处理输入,每段大小为3 * 243;然后在通过当前状态存储 字节数组 state 以及 入参trits作transform()
转换。当然,absorb 流程中,将入参trits 的内容作为 state 的初始值;因此,我们继续深入transform():
private static final byte[] TRUTH_TABLE = {1, 0, -1, 2, 1, -1, 0, 2, -1, 1, 0};
private final byte[] state;
// STATE_LENGTH = 3 * 243
private final byte[] scratchpad = new byte[STATE_LENGTH];
private void transform() {
int scratchpadIndex = 0;
int prevScratchpadIndex = 0;
// 转换轮次numberOfRounds(27/81)
for (int round = 0; round < numberOfRounds; round++) {
// 将没一轮次的转换结果state 全量拷贝至scratchpad
System.arraycopy(state, 0, scratchpad, 0, STATE_LENGTH);
for (int stateIndex = 0; stateIndex < STATE_LENGTH; stateIndex++) {
prevScratchpadIndex = scratchpadIndex;
if (scratchpadIndex < 365) {
scratchpadIndex += 364;
} else {
scratchpadIndex += -365;
}
state[stateIndex] = TRUTH_TABLE[scratchpad[prevScratchpadIndex] + (scratchpad[scratchpadIndex] << 2) + 5];
}
}
}
上面的转换算法比较简单易读,核心是反复对自身转换的结果按照同样的方式进行转换指定次数(27 或者 81 次),第一个for
主要是控制转换轮次,并将上一次转换结果全量拷贝至辅助状态scratchpad 中(两者都是长度相同的字节数组);第二个for 循环式执行正真的转换,根据上次转换结果scratchpad 作为参考转。
转换方式如下:
stateIndex
从0到STATE_LENGTH
重新计算每一个state[stateIndex]
的数值。计算方式为TRUTH_TABLE[scratchpad[prevScratchpadIndex] + (scratchpad[scratchpadIndex] << 2) + 5]
。即根据scratchpad[prevScratchpadIndex] + (scratchpad[scratchpadIndex] << 2) + 5
作为index,从TRUTH_TABLE
获取相应的整数值。scratchpadIndex 从0开始,每一次for 循环作一次变更,变更条件是如果当前scratchpadIndex
值小于365,则加364,否则减365;而prevScratchpadIndex
则取上一次scratchpadIndex
的值。上述所使用的是(代换-置换网络)算法实现。
到这里,Sponge 算法详解 分析完毕。