Portfolio
BACK_TO_JOURNAL
#Cryptography#C##Algorithms#Security

Implementing RIPEMD-160 in C# — A Learning Journal

March 14, 2024


Why hashing

I kept asking "how does this actually work?" Not the textbook answer, but the real one. What does "mixing" mean inside the compression function? Why five rounds and not three? Why two parallel paths that only meet at the very end?

So I went looking for a real spec. Found the original RIPEMD-160 paper by Preneel, Dobbertin and Bosselaers, The Cryptographic Hash Function RIPEMD-160, CryptoBytes 3(2), 1997. Twelve dense pages, surprisingly generous with worked examples. A dear colleague of mine had been poking at similar stuff, so we read it together, sketched the round structure on a notepad until it clicked, and from there 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: take any input, produce a 160-bit hash. Internally, five 32-bit state words A B C D E, plus five mirrored ones A' B' C' D' E'. Split the padded input into 512-bit blocks. For each block, run 80 steps on the left line and 80 on the right line in parallel, then fold the two lines 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

Repeat it 160 times with the right permutations and you have the algorithm.


The code shape

I split things by concern rather than 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 tiny 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 cleverness lives in the providers.


Three highlights from the implementation

1. The two lines really are independent. Same five Boolean functions, opposite direction. The right line indexes them as 79 - i instead of i. Different constants per round, different message word order, different shifts. The post-loop fold into the state 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 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 are constrained to anti-pattern rules: not all the same parity, totals not divisible by 32, not too many divisible by four. Cryptographers care about patterns the way the rest of us care about typos.

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 the designers dropped the "majority" function MD4 used because it was too symmetric and a performance liability.


The endianness lesson

This is the bug that taught me the most.

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

My Extensions.cs ended up with three little-endian helpers. Two for uint, 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 first version of the compression function skipped the byte-reverse call. Empty string hashed wrong. I added it. Then "a" hashed wrong in a different way. Checked the spec, checked the test vectors, checked the spec again. The problem: I had two overloads of ToLittleEndian. One returning a string (for printing), one returning a uint (for arithmetic), and I was calling the wrong one inside the inner loop. The first failure was off in the high bits; the second was off everywhere. That second failure was the giveaway: a partial fix that only works for some inputs is almost always a layering bug, and the layering here was which overload got called.

Renamed one of them. Have not stopped being grateful to the test vectors since.


The tests are the whole point

RipemdTests/UnitTest1.cs is just a pile of [Fact]s, each one a known input paired with its published RIPEMD-160 output:

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

The 1,000,000-a test forces the padding across many blocks, which catches any block-boundary bug. The 80-times-"1234567890" test exercises a different multiple of 512 bits. Every [Fact] is a spec violation the compiler will yell about.

I ran those tests probably a hundred times during development. They are the only reason the project is correct.


What it taught me

  • Specs aren't code. Twelve pages of formal description and there's still ambiguity. What endianness at which stage, partial blocks at the end, indexing conventions. Going from spec to working code isn't transcription, it's interpretation.
  • Tables of constants are the algorithm. Treat them with the same care as code, because they are code, in a different syntax.
  • The slow version is fine. String-of-bits is the slowest representation possible. It doesn't matter. I'd rather have a slow correct program than a fast one I'm not sure about, I may refactor when i get the time though.
  • The Intro/ project was a bridge. Caesar and Vigenere ciphers in the same repo were the on-ramp. They got me comfortable with "string in, string out, lots of integer math in between." I don't think I would have finished the bigger project without doing 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.