DEV Community

Bracketly
Bracketly

Posted on

I Implemented MD5 From Scratch in JavaScript (Because Web Crypto Won't)

I Implemented MD5 From Scratch in JavaScript (Because Web Crypto Won't)

While building Bracketly — a small set of free, client-side developer tools — I hit an annoying gap while writing the hash generator: the browser's native crypto.subtle.digest() supports SHA-1, SHA-256, SHA-384, and SHA-512, but not MD5.

That's deliberate. MD5 is cryptographically broken — collisions can be deliberately engineered — so the Web Crypto API spec simply doesn't include it. But MD5 is still one of the most-searched hash algorithms, because it's everywhere in non-security contexts: file integrity checksums, legacy system compatibility, cache keys, dedup fingerprints. People still need to compute it, even though nobody should use it for anything security-sensitive anymore.

So: no native API, tool still needs to support it, only option left is implementing the algorithm myself.

The algorithm, briefly

MD5 processes a message in 512-bit (64-byte) chunks through four rounds of 16 operations each — 64 operations total per chunk — each round using a different nonlinear function (F, G, H, I) mixing three of four 32-bit state words, plus a precomputed constant table K (the integer parts of the sines of 1 to 64, in radians) and a set of per-round left-rotation amounts.

The message has to be padded first: append a single 1 bit, then zero bits until the length is congruent to 448 mod 512, then append the original bit-length as a 64-bit little-endian integer. Get this padding wrong by even one byte and every hash silently comes out wrong — there's no error, just garbage output that happens to be valid-looking hex.

Here's the padding and main compression loop:

function md5(str) {
  function rotl(n, s) { return (n << s) | (n >>> (32 - s)); }
  const K = new Uint32Array([
    0xd76aa478, 0xe8c7b756, 0x242070db, 0xc1bdceee, /* ...64 total */
  ]);
  const S = [7,12,17,22, 7,12,17,22, 7,12,17,22, 7,12,17,22,
             5, 9,14,20, 5, 9,14,20, 5, 9,14,20, 5, 9,14,20,
             4,11,16,23, 4,11,16,23, 4,11,16,23, 4,11,16,23,
             6,10,15,21, 6,10,15,21, 6,10,15,21, 6,10,15,21];

  const msg = new TextEncoder().encode(str);
  const origLenBits = msg.length * 8;
  const padLen = (msg.length % 64 < 56) ? (56 - msg.length % 64) : (120 - msg.length % 64);
  const padded = new Uint8Array(msg.length + padLen + 8);
  padded.set(msg);
  padded[msg.length] = 0x80;
  const view = new DataView(padded.buffer);
  view.setUint32(padded.length - 8, origLenBits >>> 0, true);
  view.setUint32(padded.length - 4, Math.floor(origLenBits / 0x100000000), true);

  let a0 = 0x67452301, b0 = 0xefcdab89, c0 = 0x98badcfe, d0 = 0x10325476;

  for (let chunk = 0; chunk < padded.length; chunk += 64) {
    const M = new Uint32Array(16);
    for (let i = 0; i < 16; i++) M[i] = view.getUint32(chunk + i * 4, true);
    let A = a0, B = b0, C = c0, D = d0;
    for (let i = 0; i < 64; i++) {
      let F, g;
      if (i < 16)      { F = (B & C) | (~B & D);        g = i; }
      else if (i < 32) { F = (D & B) | (~D & C);        g = (5 * i + 1) % 16; }
      else if (i < 48) { F = B ^ C ^ D;                 g = (3 * i + 5) % 16; }
      else             { F = C ^ (B | ~D);              g = (7 * i) % 16; }
      F = (F + A + K[i] + M[g]) >>> 0;
      A = D; D = C; C = B;
      B = (B + rotl(F, S[i])) >>> 0;
    }
    a0 = (a0 + A) >>> 0; b0 = (b0 + B) >>> 0; c0 = (c0 + C) >>> 0; d0 = (d0 + D) >>> 0;
  }
  // ...serialize a0-d0 as little-endian hex
}
Enter fullscreen mode Exit fullscreen mode

The trickiest part wasn't the algorithm itself, it was getting the endianness right — MD5 spec is little-endian throughout (message word loading, final digest byte order), which is easy to get backwards if you're used to SHA's big-endian convention. Get it backwards and you get a hash that's wrong in a way that's hard to spot just by eyeballing it (still 32 hex chars, still looks plausible).

Verifying it

Never trust a from-scratch crypto implementation without checking it against known-good vectors. RFC 1321 (and general convention) gives:

md5("")    → d41d8cd98f00b204e9800998ecf8427e
md5("abc") → 900150983cd24fb0d6963f7d28e17f72
md5("The quick brown fox jumps over the lazy dog") → 9e107d9d372bb6826bd81d3542a419d6
Enter fullscreen mode Exit fullscreen mode

All three matched on the first attempt after fixing the endianness bug above — which is a relief, because subtle bugs in hash implementations are exactly the kind of thing that pass casual testing and then produce wrong output on some inputs.

Where it ended up

It's live as one of the tools on Bracketly alongside SHA-1/256/512 (which just call crypto.subtle.digest directly, no reinventing needed there). Everything runs client-side — nothing typed in ever leaves the browser, which is the whole point of the site: a handful of free developer utilities (JSON formatter, Base64, JWT decoder, URL encoder, UUID generator, regex tester, color converter, timestamp converter) with no backend, no accounts, no tracking.

Source is on GitHub if you want to see the rest: https://github.com/GRimkiller360/bracketly

Top comments (0)