DEV Community

Cover image for Ethereum cryptography from a Go dev's view
Mohsen
Mohsen

Posted on

Ethereum cryptography from a Go dev's view

I built an Ethereum wallet CLI in Go because I wanted to understand what a wallet actually does. Not just:

generate a key
create an address
sign a transaction
done
Enter fullscreen mode Exit fullscreen mode

That was my mental model at the beginning. After implementing the pieces myself, I realized a wallet is less like a key container and more like a collection of cryptographic decisions, where small implementation details can break compatibility, correctness, or security.

This post is about what I learned while building mini-wallet, and the cryptography details that surprised me.

The project is built for learning, not production key management.

Why I built a wallet CLI instead of just reading about crypto

Reading about cryptography is useful. Implementing it is different.

Before this project, my view of wallets was much simpler: I thought they were mostly responsible for transferring assets. Once I started implementing the pieces myself, I started seeing the hidden complexity:

  • How random data becomes a human-readable recovery phrase.
  • How one seed generates thousands of keys.
  • How signatures can recover public keys.
  • Why different tools sometimes represent the same value differently.
  • Why compatibility tests matter as much as the algorithms themselves.

I chose to build a CLI wallet because you understand a system differently when you are responsible for making every part work. A library can hide complexity; building the library exposes it.

Entropy → mnemonic (BIP39)

Most wallets start with entropy. For a 12-word mnemonic, BIP39 starts with 128 bits of random entropy, then calculates SHA-256 of that entropy and takes the first 4 bits as the checksum:

128 bits entropy + 4 bits checksum = 132 bits
Enter fullscreen mode Exit fullscreen mode

Those 132 bits are split into groups of 11 bits. Each 11-bit number is an index between 0 and 2047, and BIP39 has a word list with exactly 2048 words, so each index maps to one word. The result is the 12-word mnemonic.

The interesting part for me was the checksum. The wallet does not store an extra "checksum field" — the checksum is embedded in the words themselves. In my implementation it is calculated directly from the entropy:

checksumBits := bits / 32

hash := sha256.Sum256(entropy)
firstByteBits := fmt.Sprintf("%08b", hash[0])
checksum := firstByteBits[:checksumBits]

bitstring := ""
for _, b := range entropy {
    bitstring += fmt.Sprintf("%08b", b)
}

bitstring += checksum
Enter fullscreen mode Exit fullscreen mode

The resulting bit string is then split into 11-bit chunks and mapped to the BIP39 word list:

entropy
   |
SHA-256
   |
checksum bits
   |
entropy + checksum
   |
11-bit groups
   |
BIP39 words
Enter fullscreen mode Exit fullscreen mode

When recovering the wallet, the process runs in reverse: the words are converted back into bits, the entropy is separated from the claimed checksum, SHA-256 is calculated again, and the two checksums are compared. A handful of bits is enough to catch most human input mistakes.

Seed → keys (BIP32/BIP44)

The mnemonic is not the private key. It is used to generate a seed, and that seed becomes the root of a hierarchical deterministic (HD) wallet:

mnemonic
   |
   v
seed
   |
   v
master key
   |
   +---- account
   |       |
   |       +---- address
   |       +---- address
   |
   +---- account
           |
           +---- address
Enter fullscreen mode Exit fullscreen mode

BIP32 lets us derive a whole tree of keys from one master key. The most important detail here is the difference between hardened and non-hardened derivation.

Hardened derivation

With hardened derivation, creating a child key requires the parent private key — an xpub alone is not enough. In the path parser, I mark hardened indices by adding HardenedOffset:

hardened := strings.HasSuffix(seg, "'")
if hardened {
    seg = seg[:len(seg)-1]
}

n, err := strconv.ParseUint(seg, 10, 32)
if err != nil {
    return nil, fmt.Errorf("invalid path segment %q: %w", seg, err)
}

index := uint32(n)

if !hardened && index >= HardenedOffset {
    return nil, fmt.Errorf("non-hardened index %d exceeds 2^31", index)
}

if hardened {
    index += HardenedOffset
}
Enter fullscreen mode Exit fullscreen mode

So:

44'  -> 44 + 0x80000000
60'  -> 60 + 0x80000000
0    -> 0
0    -> 0
5    -> 5
Enter fullscreen mode Exit fullscreen mode

Non-hardened derivation

With non-hardened derivation, someone with only an xpub can derive child public keys. That is useful for watch-only wallets, but it has a dangerous edge case. If an attacker has:

  • the parent xpub
  • one child private key

they can compute the parent private key — and once that is compromised, the whole subtree below it is compromised too. This is the well-known xpub + child-private-key attack, and it is why the first three levels of the path are hardened.

Ethereum uses the BIP44 path:

m/44'/60'/0'/0/n
Enter fullscreen mode Exit fullscreen mode

Meaning:

  • m → master key
  • 44' → BIP44 purpose
  • 60' → Ethereum
  • 0' → account index
  • 0 → change
  • n → address index

The default path is also what my CLI uses when deriving Ethereum addresses.

secp256k1 signing and recovery

Ethereum uses the secp256k1 elliptic curve. A signature is usually written as:

(r, s, v)
Enter fullscreen mode Exit fullscreen mode

The interesting part is v. Many cryptographic systems send the public key together with the signature. Ethereum does something different: the signature carries enough information to recover the public key.

signature
    |
    v
recover public key
    |
    v
derive Ethereum address
Enter fullscreen mode Exit fullscreen mode

This matters because an Ethereum transaction has no from field. The sender is recovered from the signature, which saves 64 bytes of public key per transaction.

It is also where compatibility problems start showing up, because different tools represent v differently:

  • go-ethereum's SigToPub expects a recovery ID of 0/1
  • wallet tooling can provide 27/28

They carry the same recovery information, but they are not interchangeable byte-for-byte. I ran into exactly this problem. Instead of teaching the CLI about every wallet's signature format, I moved the compatibility logic into the signer layer:

if v := sig[64]; v == 27 || v == 28 {
    normalised = make([]byte, 65)
    copy(normalised, sig)
    normalised[64] = v - 27
}

hash := hashPersonalMessage(message)
pubKey, err := crypto.SigToPub(hash.Bytes(), normalised)
if err != nil {
    return common.Address{}, err
}

return crypto.PubkeyToAddress(*pubKey), nil
Enter fullscreen mode Exit fullscreen mode

Now RecoverPersonal accepts either representation. A small detail — but exactly the kind that makes two otherwise-correct implementations incompatible.

There is a third use of v that is easy to confuse with these. For transactions, EIP-155 uses v for replay protection:

v = chainId * 2 + 35/36
Enter fullscreen mode Exit fullscreen mode

That is different from the 0/1 recovery ID of the signing primitive and from the 27/28 convention of personal-message signing. Same field, three conventions.

Keystore V3: encryption is more than encrypt/decrypt

Ethereum keystore files protect private keys with three pieces:

scrypt

scrypt is the key derivation function: it turns the user's password into a 32-byte derived key. That key is split in two:

derivedKey, err := deriveKey(password, salt)
if err != nil {
    return KeystoreV3{}, err
}

encKey := derivedKey[:16]
macKey := derivedKey[16:32]
Enter fullscreen mode Exit fullscreen mode

The first 16 bytes become the AES key; the remaining 16 bytes are used for the MAC.

AES-128-CTR

The private key is encrypted using AES-128 in CTR mode:

block, err := aes.NewCipher(encKey)
if err != nil {
    return KeystoreV3{}, err
}

stream := cipher.NewCTR(block, iv)
ciphertext := make([]byte, len(secret))
stream.XORKeyStream(ciphertext, secret)
Enter fullscreen mode Exit fullscreen mode

CTR turns AES into a stream cipher, so the ciphertext is exactly as long as the private key and no padding is involved.

MAC

The MAC protects the ciphertext against modification:

keccak256(derivedKey[16:32] ‖ ciphertext)
Enter fullscreen mode Exit fullscreen mode

In code:

h := sha3.NewLegacyKeccak256()
h.Write(macKey)
h.Write(ciphertext)
mac := h.Sum(nil)
Enter fullscreen mode Exit fullscreen mode

So the overall flow looks like:

password
   |
   v
  scrypt
   |
   v
derived key
   |
   +---- first 16 bytes ----> AES-128-CTR key
   |
   +---- last 16 bytes -----> MAC key
                                |
ciphertext <--------------------+
   |
   v
keccak256(macKey || ciphertext)
   |
   v
MAC
Enter fullscreen mode Exit fullscreen mode

At first, a simple round-trip test looks like enough:

encrypt
   |
decrypt
   |
same private key
Enter fullscreen mode Exit fullscreen mode

But this can hide bugs: if encryption and decryption share the same mistake, the test still passes. That is why reference vectors matter — they test compatibility with the actual format, not with your own implementation.

One of the bugs I hit was hard-coded scrypt parameters. The reference vector intentionally used:

p = 8
r = 1
Enter fullscreen mode Exit fullscreen mode

while my decryption path had the parameters hard-coded instead of reading them from the keystore JSON. My own round-trip tests passed, because both sides shared the same assumption. The reference vector did not. That was a much more useful failure.

Messages, not hashes

A signature alone does not explain what was signed — a hash is just 32 bytes. The same signing primitive could be used for:

  • a transaction
  • a login message
  • a smart contract interaction
  • structured application data

This is why Ethereum has standards such as EIP-191 and EIP-712.

EIP-191

For personal messages, the message is not hashed directly. Ethereum adds a prefix that includes the message length:

"\x19Ethereum Signed Message:\n" + len(message) + message
Enter fullscreen mode Exit fullscreen mode

My implementation does exactly that:

func hashPersonalMessage(message []byte) common.Hash {
    prefix := fmt.Sprintf("\x19Ethereum Signed Message:\n%d", len(message))

    data := append([]byte(prefix), message...)
    return crypto.Keccak256Hash(data)
}
Enter fullscreen mode Exit fullscreen mode

The \x19 byte is not a valid start of an RLP-encoded transaction, so a signed message can never be replayed as a transaction. The signature is tied to the personal-message format instead of being a raw signature over arbitrary bytes.

EIP-712

For structured data, EIP-712 goes further. Instead of an opaque blob, it defines typed data with a schema, a domain separator, and a structured hashing process. The final digest is:

keccak256(
    "\x19\x01" ||
    domainSeparator ||
    hashStruct(message)
)
Enter fullscreen mode Exit fullscreen mode

The last step of my implementation:

data := append([]byte("\x19\x01"), domainHash...)
data = append(data, messageHash...)

return crypto.Sign(crypto.Keccak256(data), priv)
Enter fullscreen mode Exit fullscreen mode

The domain separator binds the signature to context such as:

  • application
  • version
  • chain ID
  • verifying contract

In my tests, a signature made with chainId = 1 recovers to a different address when checked against chainId = 999 — that is the domain separator doing its job.

Three bugs my tests caught

This was the most valuable part of the project. The biggest lessons did not come from implementing algorithms; they came from failing tests.

Bug 1: v format mismatch

I took signatures with v = 27/28 from wallet tooling and passed them straight into SigToPub, which expects 0/1. Recovery returned the wrong address.

My first fix was inside the CLI command. Then I realized the conversion belonged in the signer layer: RecoverPersonal should accept both formats and normalize before recovery.

The lesson:

Compatibility logic should live where the concept belongs.

Bug 2: hard-coded scrypt parameters

My keystore tests passed — because encryption and decryption made the same assumption. The reference vector intentionally used p=8, r=1, while my decryption path ignored the parameters in the keystore JSON. The implementation was consistently wrong, and only the external vector exposed it.

The lesson:

Passing your own tests does not always mean you implemented the standard correctly.

Reference vectors are not optional polish for cryptographic code. They are part of the implementation.

Bug 3: unsafe type assertion in HashStruct

The third bug was in EIP-712 HashStruct. When processing a nested typed-data field, I assumed the value was always a map[string]any:

nested := value.(map[string]any)
Enter fullscreen mode Exit fullscreen mode

A bare type assertion panics when the input has the wrong shape. The fix:

nested, ok := value.(map[string]any)
if !ok {
    return nil, fmt.Errorf(
        "field %s must be a nested struct (map[string]any), got %T",
        field.Name,
        value,
    )
}
Enter fullscreen mode Exit fullscreen mode

Now malformed typed data produces an error instead of crashing the process.

The lesson:

Crypto security is not only about cryptographic algorithms.

Parsing, validation, and handling unexpected input are part of security too.

What's next

The next milestone is smaller than I originally thought. v0.1.0 is the MVP tag:

  • EIP-712 in the CLI
  • tag v0.1.0
  • call the MVP done

After that, transaction lifecycle work moves to a separate project, blockchain-insight:

  • transaction creation
  • signing
  • broadcasting
  • tracking transaction status

Separating those concerns is another thing this project taught me: a wallet does not need to become an entire blockchain application just because it can sign messages.

Final thoughts

The biggest thing I learned is that cryptography is not just mathematics — it is also engineering. The algorithms are one part of the problem. The other part is everything around them:

  • standards
  • byte formats
  • compatibility
  • serialization
  • validation
  • reference vectors
  • error handling
  • API boundaries

A 27 instead of a 0. A hard-coded KDF parameter. A type assertion without ok. Each one looks like a tiny detail, and each one is the difference between "works on my machine" and an implementation that actually interoperates with the Ethereum ecosystem.

Building this wallet changed how I look at Ethereum. I no longer see a wallet as a key container; I see it as a system where every small decision matters. That is probably the most useful thing I got from building it myself.


Code: github.com/mohsenm4/mini-wallet. Built for learning — not for production key management.

Top comments (0)