Portfolio
BACK_TO_JOURNAL
#Cryptography#C##Algorithms#Security

Implementing RIPEMD-160 in C# — A Learning Journal

March 14, 2024


Why hashing

I wanted to know how hashing actually works, not the textbook version. What does mixing mean inside the compression function. Why five rounds and not three. Why two parallel lines that only meet at the very end.

So I found the original spec: the RIPEMD-160 paper by Preneel, Dobbertin and Bosselaers, The Cryptographic Hash Function RIPEMD-160, CryptoBytes 3(2), 1997. Twelve dense pages, and they actually include worked examples, which most papers promise and don't deliver. A colleague was looking at similar stuff, so we read it together and sketched the round structure until it clicked. Then I went off and built it. The code in barnabasSol/Cryptography is mine, but the understanding is a joint project.


The algorithm, briefly

RIPEMD-160 takes any input and produces a 160-bit hash. Internally there are five 32-bit state words A B C D E, plus five mirrored ones A' B' C' D' E'. The padded input splits into 512-bit blocks. For each block you run 80 steps on the left line and 80 on the right line in parallel, then fold both lines back into the state. Three primitives: 32-bit add mod 2³², left rotation, and the bitwise operators. That's the whole machine.

The step equation is one line:

A := (A + f(B, C, D) + X + K) <<< s + E
C := C <<< 10

Run it 160 times with the right permutations and that's the algorithm.


The code shape

I split things by concern, not by class size:

Ripemd160/
├── Program.cs                  # entry point
└── Utils/
    ├── InputProvider.cs        # ASCII → bits, MD-family padding
    ├── BufferProvider.cs       # the 5+5 state words + working registers
    ├── CompressionProvider.cs  # the 80-step round
    ├── Constants.cs            # K values for both lines
    ├── FunctionProvider.cs     # the five nonlinear f functions
    ├── MessageWord.cs          # message word permutations ρ and π
    ├── ShiftProvider.cs        # per-step rotation amounts
    └── Extensions/Extensions.cs

Program.cs stays small on purpose:

string input = InputProvider.InitialProcess(i[0]);
var chunk = input.Chunk(512);
var buf = new BufferProvider();

foreach (var block in chunk)
{
    buf.Set();
    CompressionProvider.ComputeHash(block, buf);
}
Console.WriteLine(buf.HashResult(), ConsoleColor.Green);

Read, pad, chunk, compress, print. The interesting code lives in the providers.


Three things worth knowing

1. The two lines really are independent. Same five Boolean functions, opposite direction, the right line indexing them as 79 - i instead of i. Different constants, different message word order, different shifts. The fold back into the state afterwards is also not the obvious H[i] += left[i] + right[i]. It's a cross-add that shuffles which right-line register lands in which state slot. I almost wrote the simple version. The test vectors would have caught it.

2. Three tables carry the whole design. The constants, the message word permutations, the rotation amounts. Those tables are the algorithm. The permutations use ρ(i) = [7,4,13,1,10,6,15,3,12,0,9,5,2,14,11,8] and its powers, plus π(i) = 9i + 5 mod 16 for the right line. The shifts follow deliberate anti-patterns: not all the same parity, totals not divisible by 32, not too many divisible by four. The designers cared about these patterns way more than I expected.

3. The five Boolean functions. Round 1 is XOR. Rounds 2 and 4 are muxes ((x & y) | (~x & z) and its mirror). Round 3 mixes. Round 5 is XOR with an OR-flip. The paper notes they dropped MD4's "majority" function because it was too symmetric and too slow.


The endianness bug

RIPEMD-160, like MD4 and MD5, is little-endian. SHA-1 is big-endian. I'd never had to think about it before because every library I used hid it. Doing it by hand, it matters.

Extensions.cs ended up with three little-endian helpers, two for uint and one for strings. The string one reverses 8-bit chunks inside a 32-bit (or 64-bit) word:

public static string ToLittleEndian(this string value)
{
    return string.Concat(value.Chunk(8).Reverse().Select(s => new string(s)));
}

The tests are the point

RipemdTests/UnitTest1.cs is a pile of [Fact]s, each pairing a known input with its published output:

Assert.Equal("9c1185a5c5e9fc54612808977ee8f548b2258d31", ComputeHash(""));
Assert.Equal("8eb208f7e05d987a9b044a8e98c6b087f15a0bfc", ComputeHash("abc"));

The 1,000,000-a case spreads padding across many blocks, so it catches block-boundary bugs. The 80-times-"1234567890" case exercises a different multiple of 512 bits. Every [Fact] is a spec violation waiting to be caught.

I ran them maybe a hundred times during development. They're the only reason the project is correct.


What I took from it

  • Specs aren't code. Twelve dense pages and there's still ambiguity: which endianness at which stage, partial blocks at the end, indexing conventions. Going from spec to code is interpretation, not transcription.
  • Tables of constants are the algorithm. Give them the same care as code, because they are code in a different syntax.
  • Slow is fine. I used string-of-bits, the slowest representation possible. It doesn't matter. A slow correct program beats a fast one I'm not sure about. I may refactor when I get the time.
  • The Intro/ project was a bridge. Caesar and Vigenere ciphers in the same repo got me comfortable with "string in, string out, integer math in between." I don't think I'd have finished the real project without the small one first.

References

  • Paper: Preneel, B., Dobbertin, H., Bosselaers, A. The Cryptographic Hash Function RIPEMD-160. CryptoBytes 3(2), pp. 9–14, 1997. RSA Laboratories.
  • Code: github.com/barnabasSol/Cryptography, C# implementation of RIPEMD-160 with xUnit test vectors, plus a small Intro/ project with Caesar and Vigenere.