Modernizing Steem.js — Day 2: Replacing the Crypto Core with noble
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 pointscreate-hash,create-hmac,ripemd160— hashingbrowserify-aes— memo encryptionsecure-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:
| Old | New |
|---|---|
bigi + ecurve | noble/curves/secp256k1 |
create-hash, create-hmac, ripemd160 | noble/hashes (sha256, sha512, ripemd160, hmac) |
browserify-aes | noble/ciphers (AES-256-CBC) |
secure-random / randombytes | randomBytes from Fnoble/hashes (WebCrypto-backed) |
bs58 base58 plumbing | scure/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
- 🛠️ Code (fork): https://github.com/blazeapps007/steem-js (
BlazeDevelopmentbranch) - 📖 Documentation: https://blazeapps007.github.io/steem-js/
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