Modernizing Steem.js — Day 2: Replacing the Crypto Core with noble

in Steem Devyesterday

This is part 2 of my 7-part series on modernizing Steem.js. Yesterday I covered the safety net and tooling. Today is the heart of the whole project — and the scariest part: swapping out the entire cryptography stack while keeping every byte identical.

The problem with the old crypto stack

The original library leaned on a tower of aging, Node-shim crypto packages:

  • bigi + ecurve — big-integer math and elliptic-curve points
  • create-hash, create-hmac, ripemd160 — hashing
  • browserify-aes — memo encryption
  • secure-random / randombytes — entropy

These work, but they assume Node's Buffer and crypto shims. On edge runtimes there is no Buffer; on Deno the ESM story is rough; in Vite you need polyfill plugins just to bundle them. They also drag in a lot of weight. (Two listed deps — native secp256k1 and noble-secp256k1 — turned out to be dead weight, never imported at all.)

The replacement: noble + scure

Everything moved to the zero-dependency, audited, pure-JS noble family:

OldNew
bigi + ecurvenoble/curves/secp256k1
create-hash, create-hmac, ripemd160noble/hashes (sha256, sha512, ripemd160, hmac)
browserify-aesnoble/ciphers (AES-256-CBC)
secure-random / randombytesrandomBytes from Fnoble/hashes (WebCrypto-backed)
bs58 base58 plumbingscure/base

Pure JS, no Buffer, no Node built-ins — so it bundles cleanly everywhere.

The hard part: byte-exact signatures

Steem (graphene) signatures aren't just "any valid ECDSA signature." They use a canonical form: deterministic RFC6979 nonce generation, with a retry loop that bumps the nonce until both r and s serialize to exactly 32 bytes and s is low. Get this even slightly wrong and signatures still verify mathematically but don't match what the chain and existing tooling produce.

I ported the graphene signing loop verbatim onto noble:

// signature.js — canonical nonce-retry, ported byte-for-byte
let nonce = 0;
let e = bytesToBig(sha256(buf));
while (true) {
  const sig = secp.sign(hash, privateKey.toBuffer(), { extraEntropy: ... });
  // graphene canonical check: r and s must both be 32-byte DER ints
  if (derIntLen(sig.r) === 32 && derIntLen(sig.s) === 32) {
    // recovery param → graphene's i = recovery + 31 (compressed)
    return new Signature(sig.r, sig.s, recovery + 31);
  }
  nonce++;
}

A new curve.js centralizes the secp256k1 point, the curve order n, and the bigint ↔ bytes helpers — built on native BigInt instead of bigi.

Removing the browser-entropy hack

The old key_utils.js mixed in entropy from window.screen, navigator, and window.location — a browser-only hack that doesn't exist on edge/Deno and was never needed once you have a real CSPRNG. It's gone, replaced by WebCrypto-backed randomBytes.

Did it work?

The golden vectors from Day 1 were the judge:

  • ✅ WIF derivation — identical
  • ✅ Public keys — identical
  • ✅ Canonical signature — identical
  • ✅ Memo encrypt/decrypt roundtrip — identical

6/6 green. Same keys, same signatures, same ciphertext — now on a modern, edge-safe crypto core.

Tomorrow: Phase 3 — replacing bytebuffer in the serializer with a native Uint8Array + BigInt implementation.

Links

Support Secure Steem Development

If you value proactive engineering, UX polish, and performance optimizations for the STEEM ecosystem, please consider supporting my witness: blaze.apps

🗳️ Vote Here:
Vote for blaze.apps Witness

Claude Code used as assistant to prepare this post