- Feature name:
ternary-hash
- Start date: 2019-10-15
- RFC PR: iotaledger/bee-rfcs#34
- Bee issue: iotaledger/bee#77
Summary
This RFC proposes the ternary Hash
type, the Sponge
trait, and two cryptographic hash functions CurlP
and Kerl
implementing it. The 3 cryptographic hash functions used in the current IOTA networks (i.e. as of IOTA Reference
Implementation iri v1.8.6
) are Kerl
, CurlP27
, and CurlP81
.
Useful links:
- Sponge function
- The sponge and duplex constructions
- Curl-p
- Kerl specification
iri v1.8.6
- IOTA transactions
Motivation
In order to participate in the IOTA network, an application needs to be able to construct valid messages that can be verified by other participants in the network. Among other operations, this is accomplished by validating transaction signatures, transaction hashes and bundle hashes.
The two hash functions currently used are both sponge constructions: CurlP
, which is specified entirely in balanced
ternary, and Kerl
, which first converts ternary input to a binary representation, applies keccak-384
to it, and then
converts its binary output back to ternary. For CurlP
specifically, its variants CurlP27
and CurlP81
are used.
Detailed design
Hash
This RFC defines a ternary type Hash
which is the base input and output of the Sponge
.
The exact definition is an implementation detail but an example definition could
simply be the following:
#![allow(unused)] fn main() { struct Hash([i8; HASH_LENGTH]); }
Where the length of a hash in units of binary-coded balanced trits would be defined as:
#![allow(unused)] fn main() { const HASH_LENGTH: usize = 243; }
Sponges
CurlP
and Kerl
are cryptographic sponge constructions. They are equipped with a memory state and a function that
replaces the state memory using some input string. A portion of the memory state is then the output. In the sponge
metaphor, the process of replacing the memory state by an input string is said to absorb
the input, while the process
of producing an output is said to squeeze
out an output.
The hash functions are expected to be used like this:
-
curlp
#![allow(unused)] fn main() { // Create a CurlP instance with 81 rounds. // This is equivalent to calling `CurlP::new(CurlPRounds::Rounds81)`. let mut curlp = CurlP81::new(); // Assume there are some transaction trits, all zeroes for the sake of this example. let transaction = TritBuf::<T1B1Buf>::zeros(6561); let mut hash = TritBuf::<T1B1Buf>::zeros(243); // Absorb the transaction. curlp.absorb(&transaction); // Squeeze out a hash. curlp.squeeze_into(&mut hash); }
-
kerl
#![allow(unused)] fn main() { // Create a Kerl instance. let mut kerl = Kerl::new(); // `Kerl::digest` is a function that combines `Kerl::absorb` and `Kerl::squeeze`. // `Kerl::digest_into` combines `Kerl::absorb` with `Kerl::squeeze_into`. let hash = kerl.digest(&transaction); }
The main proposal of this RFC are the Sponge
trait and the CurlP
and Kerl
types that are implementing it.
This RFC relies on the presence of the types TritBuf
and Trits
, as defined by
RFC36, which are assumed to be owning and
borrowing collections of binary-encoded ternary in the T1B1
encoding (one trit per byte).
#![allow(unused)] fn main() { /// The common interface of cryptographic hash functions that follow the sponge construction and that act on ternary. trait Sponge { /// An error indicating that a failure has occured during a sponge operation. type Error; /// Absorb `input` into the sponge. fn absorb(&mut self, input: &Trits) -> Result<(), Self::Error>; /// Reset the inner state of the sponge. fn reset(&mut self); /// Squeeze the sponge into a buffer. fn squeeze_into(&mut self, buf: &mut Trits) -> Result<(), Self::Error>; /// Convenience function using `Sponge::squeeze_into` to return an owned version of the hash. fn squeeze(&mut self) -> Result<TritBuf, Self::Error> { let mut output = TritBuf::zeros(HASH_LENGTH); self.squeeze_into(&mut output)?; Ok(output) } /// Convenience function to absorb `input`, squeeze the sponge into a buffer `buf`, and reset the sponge in one go. fn digest_into(&mut self, input: &Trits, buf: &mut Trits) -> Result<(), Self::Error> { self.absorb(input)?; self.squeeze_into(buf)?; self.reset(); Ok(()) } /// Convenience function to absorb `input`, squeeze the sponge, and reset the sponge in one go. /// Returns an owned version of the hash. fn digest(&mut self, input: &Trits) -> Result<TritBuf, Self::Error> { self.absorb(input)?; let output = self.squeeze()?; self.reset(); Ok(output) } } }
Following the sponge metaphor, an input provided by the user is absorb
ed, and an output will be squeeze
d from the
data structure. digest
is a convenience method calling absorb
and squeeze
in one go. The *_into
versions of
these methods are for passing a buffer, into which the calculated hashes are written. The internal state will not be
cleared unless reset
is called.
Design of CurlP
CurlP
is designed as a hash function, that acts on a T1B1
binary-encoded ternary buffer, with a hash length of 243
trits and an inner state of 729
:
#![allow(unused)] fn main() { const STATE_LENGTH: usize = HASH_LENGTH * 3; }
In addition, a lookup table is used as part of the absorption step:
-1 | 0 | 1 | |
---|---|---|---|
-1 | 1 | 1 | -1 |
0 | 0 | -1 | 1 |
1 | -1 | 0 | 0 |
Given two balanced trits t
and t'
, the following table can easily be accessed by shifting them to unbalanced trits:
#![allow(unused)] fn main() { const TRUTH_TABLE: [[i8; 3]; 3] = [[1, 0, -1], [1, -1, 0], [-1, 1, 0]]; }
The way CurlP
is defined, it can not actually fail, because the input or outputs can be of arbitrary size; hence,
the associated type Error = Infallible
.
CurlP
has two common variants depending on the number of rounds of hashing to apply before a hash is squeezed.
#![allow(unused)] fn main() { #[derive(Copy, Clone)] enum CurlPRounds { Rounds27 = 27, Rounds81 = 81, } }
Type definition:
#![allow(unused)] fn main() { struct CurlP { /// The number of rounds of hashing to apply before a hash is squeezed. rounds: CurlPRounds, /// The internal state. state: TritBuf, /// Workspace for performing transformations. work_state: TritBuf, } }
#![allow(unused)] fn main() { impl Sponge for CurlP { type Error = Infallible; ... } }
In addition, this RFC proposes two wrapper types for the very common CurlP
variants with 27
and 81
rounds.
In most use cases, Sponge
is required to implement Default
so these variants need to be new types instead of just
providing new27
or new81
methods to CurlP
.
#![allow(unused)] fn main() { struct CurlP27(CurlP); impl CurlP27 { fn new() -> Self { Self(CurlP::new(CurlPRounds::Rounds27)) } } struct CurlP81(CurlP); impl CurlP81 { fn new() -> Self { Self(CurlP::new(CurlPRounds::Rounds81)) } } }
For convenience, they should both dereference to an actual CurlP
:
#![allow(unused)] fn main() { impl Deref for CurlP27 { type Target = CurlP; fn deref(&self) -> &Self::Target { &self.0 } } impl DerefMut for CurlP27 { fn deref_mut(&mut self) -> &mut Self::Target { &mut self.0 } } impl Deref for CurlP81 { type Target = CurlP; fn deref(&self) -> &Self::Target { &self.0 } } impl DerefMut for CurlP81 { fn deref_mut(&mut self) -> &mut Self::Target { &mut self.0 } } }
This allows using them as a Sponge
as well if there is a blanket implementation like this:
#![allow(unused)] fn main() { impl<T: Sponge, U: DerefMut<Target = T>> Sponge for U { type Error = T::Error; fn absorb(&mut self, input: &Trits) -> Result<(), Self::Error> { T::absorb(self, input) } fn reset(&mut self) { T::reset(self) } fn squeeze_into(&mut self, buf: &mut Trits) -> Result<(), Self::Error> { T::squeeze_into(self, buf) } } }
Design of Kerl
Type definition:
#![allow(unused)] fn main() { struct Kerl { /// Actual keccak hash function. keccak: Keccak, /// Binary working state. binary_state: I384<BigEndian, U8Repr>, /// Ternary working state. ternary_state: T243<Btrit>, } }
#![allow(unused)] fn main() { impl Sponge for Kerl { type Error = Error; ... } }
The actual cryptographic hash function underlying Kerl
is keccak-384
. The real task here is to transform an input
of 243 (balanced) trits to 384 bits in a correct and performant way. This is done by interpreting the 243 trits as a
signed integer I
and converting it to a binary basis. In other words, the ternary encoded integer is expressed as the
series:
I = t_0 * 3^0 + t_1 * 3^1 + ... + t_241 * 3^241 + t_242 * 3^242,
where t_i
is the trit at the i
-th position in the ternary input array. The challenge is then to convert this integer
to base 2
, i.e. find a a series such that:
I = b_0 * 2^0 + b_1 * 2^1 + ... + b_382 * 2^382 + b_383 * 2^383,
with b_i
the bit at the i-th
position.
Assuming there exists an implementation of keccak
, the main work in implementing Kerl
is writing an efficient
converter between the ternary array interpreted as an integer, and its binary representation. For the binary
representation, one can either use an existing big-integer library, or write one from scratch with only a subset of
required methods to make the conversion work.
Important implementation details
When absorbing into the sponge the conversion flows like this (little and big are short for little and big endian, respectively):
Balanced t243 -> unbalanced t242 -> u32 little u384 -> u32 little i384 -> u8 big i384
When squeezing and thus converting back to ternary the conversion flows like this:
u8 big i384 -> u32 little i384 -> u32 little u384 -> unbalanced t243 -> balanced t243 -> balanced t242
These steps will now be explained in detail.
Conversion is done via accumulation
Because a number like 3^242
does not fit into any existing primitive, it needs to be constructed from scratch by
taking a big integer, setting its least significant number to 1
, and multiplying it by 3
242
times. This is the
core of the conversion mechanism. Essentially, the polynomial used to express the integer in ternary can be rewritten
like this:
I = t_0 * 3^0 + t_1 * 3^1 + ... + t_241 * 3^241 + t_242 * 3^242,
= ((...((t_242 * 3 + t_241) * 3 + t_240) * 3 + ...) * 3 + t_1) * 3 + t_0
Thus, one iterates through the ternary buffer starting from the most significant trit, adds t_242
onto the binary big
integer (initially filled with 0
s), and then keeps looping through it, multiplying it by 3 and adding the next t_i
.
Conversion is done in unsigned space
First and foremost, IOTA is primarily written with balanced ternary in mind, meaning that each trit represents an
integer in the set {-1, 0, +1}
. Because it is easier to do the conversion in positive space, the trits are shifted
into unbalanced space, by adding +1
at each position, so that each unbalanced trit represents an integer in
{0, 1, 2}
.
For example, the balanced ternary buffer (using only 9 trits to demonstrate the point) becomes after the shift (leaving out signs in the unbalanced case):
[0, -1, +1, -1, -1, 0, 0, 0, +1] -> [1, 0, 2, 0, 0, 1, 1, 1, 2]
Remembering that each ternary array is actually some integer I
, this is akin to adding another integer H
to it, with
all trits in the ternary buffer representing it set to 1
, where I'
is I
shifted into unsigned space, and the
underscore _t
means a ternary representation (either balanced or unbalanced):
I_t + H_t = [0, -1, +1, -1, -1, 0, 0, 0, +1] + [+1, +1, +1, +1, +1, +1, +1, +1] = I'_t
After changing the base to binary using some function which would be called to_bin
and which would be required to be
distributed over addition, H
needs to be subtracted again. We use _b
to signify a binary representation:
I'_b = to_bin(I'_t) = to_bin(I_t + H_t) = to_bin(I_t) + to_bin(H_t) = I_b + H_b
=>
I_b = I'_b - H_b
In other words, the addition of the ternary buffer filled with 1
s that shifts all trits into unbalanced space is
reverted after conversion to binary, where the buffer of 1
s is also converted to binary and then subtracted from the
binary unsigned big integer. The result then is the integer I
in binary.
243 trits do not fit into 384 bits
Since 243 trits do not fit into 384 bits, a choice has to be made about how to treat the most significant trit.
For example, one could take the binary big integer, convert it to ternary, and then check if the 243 are smaller than
this converted maximum 384 bit big int. With Kerl
, the choice was made to disregard the most significant trit, so that
one only ever converts 242 trits to 384 bits, which always fits.
For the direction ternary -> binary
this does not pose challenges, other than making sure that one sets the most
significant trit to 0
after the shift by applying +1
(if one chooses to reuse the array of 243 trits), and by making
sure to subtract H_b
(see previous section) with the most significant trit set to 0
and all others set to 1
.
The direction binary -> ternary
(the conversion of the 384-bit hash squeezed from keccak) is the challenging part: one
needs to ensure that the most significant trit is is set to 0 before the binary-encoded integer is converted to ternary.
However, this has to happen in binary space!
Take J_b
as the 384 bit integer coming out of the sponge. Then after conversion to ternary, to_ter(J_b) = J_t
, the
most significant trit (MST) of J_t
might be +1
or -1
. However, since by convention the MST has to be 0, one needs
to check whether J_b
would cause J_t
to have its MST set after conversion. This is done the following way:
if J_b > to_bin([0, 1, 1, ..., 1]):
J_b <- J_b - to_bin([1, 0, 0, ..., 0])
if J_b < (to_bin([0, 0, 0, ..., 0]) - to_bin([0, 1, 1, ..., 1])):
J_b <- J_b + to_bin([1, 0, 0, ..., 0])
Kerl updates the inner state by applying logical not
The upstream keccak implementation uses a complicated permutation to update the inner state of the sponge construction
after a hash was squeezed from it. Kerl
opts to apply logical not, !
, to the bytes squeezed from keccak
, and
updating keccak
's inner state with these.
Design and implementation
The main goal of the implementation was to ensure that representation of the integer was cast into types. To that end,
the following ternary types are defined as wrappers around TritBuf
(read T243
the same way you would think of u32
or i64
):
#![allow(unused)] fn main() { struct T242<T: Trit> { inner: TritBuf<T1B1Buf<T>>, } struct T243<T: Trit> { inner: TritBuf<T1B1Buf<T>>, } }
where TritBuf
, T1B1Buf
, and Trit
are types and traits defined in the bee-ternary
crate. The bound Trit
ensures
that the structs only contain ternary buffers with Btrit
(for balancer ternary), and Utrit
(for unbalanced ternary).
Methods on T242
and T243
assume that the most significant trit is always in the last position (think “little
endian”).
For the binary representation of the integer, the types I384
(“signed” integer akin to i64
), and U384
(“unsigned”
akin to u64
) are defined:
#![allow(unused)] fn main() { struct I384<E, T> { inner: T, _phantom: PhantomData<E>. } struct U384<E, T> { inner: T, _phantom: PhantomData<E>. } }
inner: T
for encoding the inner fixed-size arrays used for storing either bytes, u8
, or integers, u32
:
#![allow(unused)] fn main() { type U8Repr = [u8; 48]; type U32Repr = [u32; 12]; }
The phantom type E
is used to encode endianness, BigEndian
and LittleEndian
. These are just used as marker types
without any methods.
#![allow(unused)] fn main() { struct BigEndian {} struct LittleEndian {} trait EndianType {} impl EndianType for BigEndian {} impl EndianType for LittleEndian {} }
The underlying arrays and endianness are important, because keccak
expects bytes as input, and because Kerl
made the
choice to revert the order of the integers in binary big int.
To understand the implementation, the most important methods are:
#![allow(unused)] fn main() { T242<Btrit>::from_i384_ignoring_mst T243<Utrit>::from_u384 I384<LittleEndian, U32Repr>::from_t242 I384<LittleEndian, U32Repr>::try_from_t242 U384<LittleEndian, U32Repr>::add_inplace U384<LittleEndian, U32Repr>::add_digit_inplace U384<LittleEndian, U32Repr>::sub_inplace U384<LittleEndian, U32Repr>::from_t242 U384<LittleEndian, U32Repr>::try_from_t243 }
Drawbacks
- All hash functions, no matter if they can fail or not, have to implement
Error
;
Rationale and alternatives
CurlP
andKerl
are fundamental to theiri v1.8.6
mainnet. They are thus essential for compatibility with it;- These types are idiomatic in Rust, and users are not required to know the implementation details of each hash algorithm;
Unresolved questions
- Parameters are slice reference in both input and output. Do values need to be consumed or should new instances as return values be created?;
- Implementation of each hash functions and other utilities like HMAC should have separate RFCs for them;
- Decision on implementation of
Troika
is still unknown; - Can (should?) the
CurlP
algorithm be explained in more detail?; - Is it important to encode that the most significant trit is
0
by having aT242
?;