IOTA 基石 - Sponge 算法详解

一、概要

    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 基石 - Sponge 算法详解_第1张图片
Sponge 算法蔟

    上面为IOTA 设计的海绵函数类图,主要实现有Kerl 以及 Curl。然后下面为这俩类函数在IOTA 中的用途:

IOTA 基石 - Sponge 算法详解_第2张图片
Sponge 用途

    根据[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使用的是海绵函数:


IOTA 基石 - Sponge 算法详解_第3张图片
海绵函数

它使用有限的状态,接收任何长度的输入字节数组,然后按照指定长度字节数组输出。

海绵函数是由三个部分组成:

  • 一个内存状态 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 算法详解 分析完毕。

你可能感兴趣的:(IOTA 基石 - Sponge 算法详解)