DEV Community

Dmytro Svynarenko
Dmytro Svynarenko

Posted on • Originally published at dsvynarenko.hashnode.dev

Designing Blockchain #3: Cryptography and Wallets

Intro

In the previous article, we explored how blockchain manages accounts and state transitions through transactions. However, we left a critical security gap: anyone could create a transaction claiming to be from any account. This article addresses that fundamental problem by introducing cryptographic signatures and wallets, the mechanisms that prove ownership and secure transactions on the blockchain.

Cryptographic Primitives

Let's dive into the cryptographic foundations. From the first article, we learned about hash functions. To recap: a hash function is a cryptographic algorithm that transforms any input into a fixed-size output (digest). Any change to the input produces a completely different output. But hashing is a one-way process — we need something we can encrypt and decrypt, or sign and verify. This is where asymmetric cryptography comes in. Unlike symmetric encryption, where the same key encrypts and decrypts data, asymmetric cryptography uses two mathematically related keys: a private key (kept secret) and a public key (shared openly).

Here's how asymmetric cryptography solves our account ownership problem: when you create an account, you generate two linked keys — a private key (like a secret password only you know) and a public key (like your account number that everyone can see). To prove you own an account, you sign a transaction with your private key. This creates a unique signature that anyone can verify using your public key. If the signature checks out, it proves only the private key holder could have created it. Think of it like signing a check: anyone can see your signature and verify it's yours, but only you can actually create it.

We have a public key, but blockchains need addresses. The transformation involves several steps. First, we hash the public key to create a shorter identifier. This hashing serves multiple purposes: it makes addresses easier to work with, adds security (even if quantum computers break the key derivation, they'd still need to reverse the hash), and creates a uniform address format regardless of the cryptographic algorithm used.

Traditional algorithms like RSA produce very large keys — often 2048 or 4096 bits. This creates problems for blockchain: larger keys mean larger transactions, which means more data to store and transmit. Remember, every node stores a complete copy of the blockchain, so size matters enormously.

Elliptic Curve Cryptography (ECC) provides the same security as RSA but with much smaller keys. A 256-bit ECC key offers equivalent security to a 3072-bit RSA key — more than 10 times smaller! This efficiency is crucial for blockchain, where we process thousands of transactions, each containing signatures. Smaller keys mean faster verification, less storage, and lower network bandwidth. ECC achieves this through the mathematical properties of elliptic curves, which make certain computational problems extremely hard to solve even with smaller key sizes.

The most widely used elliptic curves in blockchain are:

  • secp256k1 — standardized by the Standards for Efficient Cryptography Group. Bitcoin chose it for its efficiency and security, and Ethereum followed.
  • Curve25519 — designed by Daniel J. Bernstein with a focus on security and performance. It's used in modern protocols and newer blockchains.

Elliptic curves support different signature schemes, each with distinct trade-offs:

  • ECDSA (Elliptic Curve Digital Signature Algorithm) is the most common scheme, used in Bitcoin and Ethereum. It produces variable-length signatures and requires careful implementation to avoid vulnerabilities.
  • Schnorr signatures, introduced in Bitcoin's Taproot upgrade, offer key advantages: they're more efficient, support signature aggregation (combining multiple signatures into one), and have simpler security proofs.
  • EdDSA (Edwards-curve Digital Signature Algorithm) uses twisted Edwards curves like Ed25519. It provides deterministic signatures — no random number generation needed — making it less prone to implementation errors. EdDSA is faster and simpler than ECDSA.

Blockchain implementations:

  • Bitcoin: Uses secp256k1 curve with ECDSA signatures. Taproot upgrade (2021) added Schnorr signature support for improved privacy and efficiency.
  • Ethereum: Uses secp256k1 curve with ECDSA signatures. Addresses are derived by taking the Keccak-256 hash of the public key and using the last 20 bytes.
  • Solana: Uses Ed25519 (EdDSA) with Curve25519. This choice prioritizes performance — signature verification is significantly faster than ECDSA.
  • Cardano: Uses Ed25519 for transaction signatures, emphasizing security through formal verification and deterministic signatures.
  • Polkadot: Uses Schnorrkel/Ristretto, a variant of Schnorr signatures built on Curve25519, enabling efficient multi-signature schemes for its nominated proof-of-stake consensus.

For our learning purposes, we'll use the more efficient and modern Ed25519 with Curve25519 for the Fleming blockchain.

Wallet

A wallet in blockchain is software that manages your cryptographic keys — it doesn't actually store cryptocurrency. The coins exist on the blockchain; the wallet stores the private keys that prove you own them. Think of it like a keychain: your house (the account) exists independently, but you need the key (private key) to access it.

Modern wallets use Hierarchical Deterministic (HD) wallets, which generate unlimited key pairs from a single seed. This seed is typically represented as a mnemonic phrase — a sequence of 12 or 24 human-readable words (like "army van defense carry jealous true garbage claim echo media make crunch"). This phrase, following the BIP-39 standard, can regenerate all your private keys, making backup and recovery much simpler than managing individual keys.

The beauty of this system: you only need to safely store those 12–24 words to recover access to all your accounts, even if you lose your device.

There's much more to say about wallets, but that's not the focus of this article. For the Fleming blockchain, I'll use BIP39 to generate key pairs from a seed phrase — it's simpler than managing raw private keys. I'll skip implementing full HD wallets for the same reason.

Implementation

Let's start with the Wallet implementation. First, we need to install new dependencies (ed25519-dalek, bip39 and rand):

[dependencies]
sha2 = "0.10.9"
hex = "0.4.3"
ed25519-dalek = "2.2.0"
bip39 = "2.2.0"
rand = "0.9.2"
Enter fullscreen mode Exit fullscreen mode

Then, add a couple of type aliases for simplicity:

use ed25519_dalek::{SigningKey as Ed25519SigningKey, VerifyingKey as Ed25519VerifyingKey};

pub type PrivateKey = Ed25519SigningKey;
pub type PublicKey = Ed25519VerifyingKey;
Enter fullscreen mode Exit fullscreen mode

Finally, out Wallet definition:

use crate::core::address::Address;
use crate::core::crypto::{PrivateKey, PublicKey};

pub struct Wallet {
    private_key: PrivateKey,   // private key
    pub public_key: PublicKey, // public key
    pub address: Address,      // address (hash of public key)
}
Enter fullscreen mode Exit fullscreen mode

Now we'll create a wallet from a mnemonic phrase using BIP-39:

impl Wallet {
    pub fn from_mnemonic(mnemonic_phrase: &str) -> Self {
        let mnemonic = Mnemonic::parse(mnemonic_phrase).expect("Invalid mnemonic phrase");
        let seed = mnemonic.to_seed(""); // don't use passphrase for our simple wallet

        // private key should be 256 bits (32 bytes) so slicing seed
        let private_key = PrivateKey::from_bytes(&seed[..32].try_into().expect("Invalid seed"));
        let public_key = private_key.verifying_key();
        let address = Address::from_public_key(public_key);

        Wallet {
            private_key,
            public_key,
            address,
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

In the Fleming blockchain, we derive the address by taking the SHA-256 hash of the public key and using the first 20 bytes (ADDRESS_LENGTH), whereas Ethereum uses Keccak-256 and takes the last 20 bytes, Bitcoin uses a more complex process with RIPEMD-160 after SHA-256 and adds checksums for error detection, and Solana simply uses the raw 32-byte Ed25519 public key as the address without hashing:

pub const ADDRESS_LENGTH: usize = 20;

#[derive(Hash, Clone, PartialEq, Eq)]
pub struct Address(String);

impl Address {
    pub fn from_public_key(public_key: &PublicKey) -> Self {
        let mut hasher = Sha256::new();
        hasher.update(public_key.as_bytes());
        let hash = hasher.finalize();

        // Like Ethereum but just first 20 bytes, not last
        Address(format!("0x{}", hex::encode(&hash[..ADDRESS_LENGTH])))
    }
}
Enter fullscreen mode Exit fullscreen mode

Next, let's add the signer's public key and signature fields to the transaction:

#[derive(Debug, Clone)]
pub struct Transaction {
    pub from: Address,
    pub to: Address,
    pub amount: u64,
    pub signer: Option<PublicKey>,
    pub signature: Option<Signature>,
}
Enter fullscreen mode Exit fullscreen mode

In Ed25519, the public key cannot be recovered from the signature alone, unlike ECDSA where public key recovery is possible through additional parameters. Therefore, we must explicitly include the signer's public key in the transaction so verifiers can validate the signature against it.

Now, add a sign method to the Wallet:

pub fn sign(&self, transaction: &mut Transaction) {
    let message = transaction.as_bytes();

    // add signer's public key and signature
    transaction.signer = Some(self.public_key);
    transaction.signature = Some(self.private_key.sign(message.as_slice()));
}
Enter fullscreen mode Exit fullscreen mode

The Wallet's sign method serialises the transaction data, attaches the signer's public key to the transaction, and creates a cryptographic signature using the private key.

Now, let's implement signature verification. We'll add a verify method to check if a transaction was actually signed by the owner of the from address:

pub fn verify_signature(&self) -> bool {
    if let (Some(public_key), Some(signature)) = (&self.signer, &self.signature) {
        let message = self.as_bytes();
        public_key.verify(&message.as_slice(), signature).is_ok()
    } else {
        false
    }
}
Enter fullscreen mode Exit fullscreen mode

The verification process reconstructs the original message from the transaction data and uses the public key to verify the signature matches. If verification succeeds, we know the transaction was signed by someone with the corresponding private key.

Now add all checks to the is_valid method:

pub fn is_valid(&self) -> bool {
    if self.signer.is_none() || self.signature.is_none() {
        return false;
    }

    if !self.verify_signature() {
        return false;
    }

    if let Some(signer) = &self.signer {
        if self.from != Address::from_public_key(signer) {
            return false;
        }
    }

    true
}
Enter fullscreen mode Exit fullscreen mode

Finally, we need to update our blockchain validation to verify transaction signatures before accepting them into blocks:

pub fn append_block(&mut self, transactions: Vec<Transaction>) {

    ...

    // apply all transactions to the state
    for tx in &transactions {
        // check transaction
        if !tx.is_valid() {
            panic!("Invalid transaction");
        }

        ...
    }

    ...
}
Enter fullscreen mode Exit fullscreen mode

The last step is updating the tests, but that's routine work. You can find the complete implementation on GitHub.

Conclusion

In this article, we've secured our Fleming blockchain by implementing cryptographic signatures and wallets using Ed25519 with Curve25519. We explored how public-key cryptography works, implemented wallet generation from mnemonic phrases using BIP-39, and added signature verification to ensure only the legitimate owner of an account can authorise transactions. With cryptographic security in place, our blockchain now prevents unauthorised transactions, but we still need to address the memory inefficiency problem of storing full state in every block — a challenge we'll tackle in the next article.

As usual all the source code from the article can be found here.

Stay tuned!

Top comments (0)