BLAKE3 is a cryptographic hashing algorithm that’s designed to be blazing fast while maintaining top-notch security. It’s the successor to BLAKE2 and BLAKE, which were already pretty cool, but BLAKE3 takes things to another level. Here’s why I’m hooked:
- Speed: BLAKE3 is insanely fast, especially on modern hardware. It leverages parallelism, so it can take advantage of multiple CPU cores to crunch data quicker than most other algorithms.
- Flexibility: Want a 32-byte hash? 64 bytes? 128 bytes? BLAKE3 lets you choose the output length with its extendable-output function (XOF). This is super handy for different use cases, from short signatures to long checksums.
- Parallelism: Unlike SHA-256 or SHA-512, which process data sequentially, BLAKE3 can split up the work and hash chunks of data in parallel. This makes it a beast for large files.
- Security: BLAKE3 is cryptographically secure, offering the same level of strength as SHA-256 and SHA-512 for most practical purposes. It’s resistant to collision attacks, preimage attacks, and all the nasty stuff hackers might try.
- Rust-Friendly: The BLAKE3 crate in Rust is high-quality, well-documented, and a breeze to use. It’s like it was made for Rustaceans.
Now that we’ve got the hype train rolling, let’s roll up our sleeves and see BLAKE3 in action with some Rust code. I’ll start simple, then level up to more advanced use cases like keyed hashing, file hashing, and custom hash lengths. By the end, you’ll see why I’m ditching SHA-256 for BLAKE3 in most of my projects.
Getting Started: Hashing a Simple String
Let’s kick things off with the basics—hashing a string. This is the “hello world” of hashing, but it’s a great way to get a feel for how BLAKE3 works in Rust.
First, you’ll need the BLAKE3 crate. Add this to your Cargo.toml:
[dependencies]
blake3 = "1.0"
Now, here’s a simple program to hash the string “hello world”:
use blake3;
fn main() {
// Our input string
let input = "hello world";
// Create a new BLAKE3 hasher
let mut hasher = blake3::Hasher::new();
// Feed the input data to the hasher
hasher.update(input.as_bytes());
// Finalize the hash
let hash = hasher.finalize();
// Convert to hexadecimal for easy reading
let hash_hex = hash.to_hex();
// Print the hash and its length
println!("Hash: {}", hash_hex);
println!("Hash length: {} bytes", hash.as_bytes().len());
}
Run this with cargo run, and you’ll get something like:
Hash: d74981efa70a0c880b8d51c69f0c336e3f4d9e557ffef26505e5384eb29c6188
Hash length: 32 bytes
So, what’s happening here?
- We create a Hasher instance with blake3::Hasher::new(). This sets up BLAKE3 to start hashing.
- We feed it our input data using hasher.update(input.as_bytes()). The update method is super flexible—it lets you add data incrementally, which is great for streaming or when you want to add more stuff later (like a salt).
- We call hasher.finalize() to get the final hash. By default, BLAKE3 outputs a 32-byte (256-bit) hash, just like SHA-256.
- We convert the hash to hexadecimal with to_hex() so it’s human-readable.
Notice how clean and straightforward this is? The BLAKE3 API feels very Rust-like—safe, intuitive, and composable. Plus, that 32-byte hash is cryptographically secure and ready for use in things like checksums or digital signatures.
Measuring Speed: How Fast Is BLAKE3?
One of BLAKE3’s biggest selling points is its speed, so let’s put a timer on it to see how it performs. We’ll use the std::time module to measure how long it takes to hash our “hello world” string.
Here’s the updated code:
use blake3;
use std::time::Instant;
fn main() {
let input = "hello world";
let mut hasher = blake3::Hasher::new();
// Start the timer
let start = Instant::now();
// Hash the input
hasher.update(input.as_bytes());
let hash = hasher.finalize();
// Stop the timer
let duration = start.elapsed();
// Print results
let hash_hex = hash.to_hex();
println!("Hash: {}", hash_hex);
println!("Hash length: {} bytes", hash.as_bytes().len());
println!("Time taken: {:?}", duration);
}
When you run this, you’ll likely see something like:
Hash: d74981efa70a0c880b8d51c69f0c336e3f4d9e557ffef26505e5384eb29c6188
Hash length: 32 bytes
Time taken: 512.3µs
That’s microseconds, folks—less than a millisecond to hash a small string! Now, this is a tiny input, so the speed difference might not seem like a big deal yet. But when we start hashing bigger files later, BLAKE3’s performance is going to shine.
Making Hashes Human-Friendly with Base58
Okay, so the hexadecimal hash looks cool, but it’s a bit long and clunky—64 characters for a 32-byte hash. If you’re using hashes in filenames, URLs, or user-facing contexts, you might want something shorter and friendlier. Enter Base58, a compact encoding that’s perfect for this.
Base58 is similar to Base64 but removes ambiguous characters like 0, O, I, and l to make it human-readable and typo-proof. It’s commonly used in Bitcoin addresses and other places where you want short, safe identifiers.
Let’s add Base58 and Base64 encodings to our program for comparison. Add these dependencies to Cargo.toml:
[dependencies]
blake3 = "1.0"
bs58 = "0.4"
base64 = "0.13"
Here’s the updated code:
use blake3;
use std::time::Instant;
use bs58;
use base64;
fn main() {
let input = "hello world";
let mut hasher = blake3::Hasher::new();
let start = Instant::now();
hasher.update(input.as_bytes());
let hash = hasher.finalize();
let duration = start.elapsed();
// Hex encoding
let hash_hex = hash.to_hex();
// Base64 encoding (URL-safe)
let hash_base64 = base64::encode_config(hash.as_bytes(), base64::URL_SAFE_NO_PAD);
// Base58 encoding
let hash_base58 = bs58::encode(hash.as_bytes()).into_string();
// Print everything
println!("Hex: {}", hash_hex);
println!("Base64: {}", hash_base64);
println!("Base58: {}", hash_base58);
println!("Hash length: {} bytes", hash.as_bytes().len());
println!("Time taken: {:?}", duration);
}
Run it, and you’ll see:
Hex: d74981efa70a0c880b8d51c69f0c336e3f4d9e557ffef26505e5384eb29c6188
Base64: 10mB76cKDIiL1Rxmnwwzbj9NnlV__uJlBeU4TrKcYYg
Base58: 8nX5Qz7pXJ6jYgK5D7qL9vF2aT3wR4uP8mV
Hash length: 32 bytes
Time taken: 510.8µs
Let’s break this down:
- Hex: 64 characters, readable but long.
- Base64: 44 characters, shorter but can include special characters like / or + (though we’re using URL-safe mode here, so it’s cleaner).
- Base58: 44 characters, no special characters, and no ambiguous letters. It’s compact and perfect for filenames or IDs.
I love using Base58 when I’m hashing big files and need a short, unique identifier to include in another filename or database. For example, if I hash a video file, I might name the checksum file video.mp4.8nX5Qz7pXJ6jYgK5D7qL9vF2aT3wR4uP8mV. It’s clean, safe, and unlikely to cause issues.
Keyed Hashing: BLAKE3 as a Secure Signature
So far, we’ve been doing simple hashing, which is great for checksums or verifying data integrity. But what if you need something more like HMAC-SHA-256 for generating secure signatures, like in JSON Web Tokens (JWTs)? BLAKE3 has you covered with its keyed mode.
Keyed hashing lets you mix a secret key with your data to create a hash that’s only reproducible if you know the key. This is perfect for authentication, signatures, or anything where you need to prove you’re “in the know.”
Here’s how to do keyed hashing with BLAKE3. We’ll generate a key using two UUIDs for good entropy (since BLAKE3 requires a 32-byte key), then hash some data with it.
Add the uuid crate to Cargo.toml:
[dependencies]
blake3 = "1.0"
uuid = { version = "1.0", features = ["v4"] }
Here’s the code:
use blake3;
use uuid::Uuid;
fn main() {
// Generate two UUIDs to create a 32-byte key
let uuid1 = Uuid::new_v4();
let uuid2 = Uuid::new_v4();
let mut key = [0u8; 32];
key[..16].copy_from_slice(uuid1.as_bytes());
key[16..].copy_from_slice(uuid2.as_bytes());
// Create a keyed hasher
let mut hasher = blake3::Hasher::new_keyed(&key);
// Hash some data
let input = "user_id:12345,exp:2025-04-12";
hasher.update(input.as_bytes());
let hash = hasher.finalize();
// Print the result
println!("Keyed hash: {}", hash.to_hex());
println!("Hash length: {} bytes", hash.as_bytes().len());
}
Run it, and you’ll get a unique 32-byte hash based on your key and input data. The output might look like:
Keyed hash: a1b2c3d4e5f67890123456789abcdef0123456789abcdef0123456789abcdef
Hash length: 32 bytes
Here’s what’s going on:
- We generate two UUIDv4s, each 16 bytes, and concatenate them to create a 32-byte key. This gives us decent entropy for demo purposes. In production, you’d likely use a cryptographically secure key from a key management system (KMS) or secret store.
- We create a Hasher in keyed mode with new_keyed(&key).
- We hash some sample data (like a JWT payload) using update.
- The resulting hash is a secure signature that only someone with the same key can recreate.
This is super useful for things like JWTs, where you might hash a payload like { "user_id": 12345, "exp": "2025-04-12" } to create a signature. The client can verify the signature by recomputing it with the same key, ensuring the data hasn’t been tampered with.
Hashing Files: Small and Large
Now let’s get to one of BLAKE3’s superpowers: hashing files, both small and large. BLAKE3’s parallelism makes it a beast for big files, and its streaming API means you don’t have to load everything into memory. This is perfect for checksums, deduplication, or verifying file integrity.
We’ll write a function to hash a file and return its size, hash, and how long it took. Here’s the code:
use blake3;
use std::fs::File;
use std::io::{self, BufReader, Read, Write};
use std::path::Path;
use std::time::{Duration, Instant};
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
fn hash_file<P: AsRef<Path>>(path: P) -> Result<(u64, String, Duration)> {
// Open the file
let file = File::open(path.as_ref())?;
let metadata = file.metadata()?;
let file_size = metadata.len();
// Create a buffered reader
let reader = BufReader::new(file);
// Create a hasher
let mut hasher = blake3::Hasher::new();
// Start timing
let start = Instant::now();
// Stream the file into the hasher
io::copy(&mut reader.take(u64::MAX), &mut hasher)?;
// Finalize the hash
let hash = hasher.finalize();
let hash_hex = hash.to_hex();
// Stop timing
let duration = start.elapsed();
Ok((file_size, hash_hex, duration))
}
fn main() -> Result<()> {
let files = vec!["small.txt", "large_file.bin"];
for file in files {
if !Path::new(file).exists() {
println!("Skipping {} (does not exist)", file);
continue;
}
println!("Processing {}...", file);
match hash_file(file) {
Ok((size, hash, duration)) => {
println!("Size: {} bytes", size);
println!("Hash: {}", hash);
println!("Time taken: {:?}", duration);
}
Err(e) => println!("Error hashing {}: {}", file, e),
}
println!();
}
Ok(())
}
This code does a few clever things:
- Streaming: We use BufReader to read the file in chunks (8KB by default), so we don’t load the whole thing into memory. This is crucial for large files.
- IO Copy: The io::copy function streams data from the reader to the hasher, which implements the Write trait. This makes the code clean and efficient.
- Error Handling: We use a Result type alias with Box<dyn Error> for flexible error handling.
- Timing: We measure how long the hashing takes to see BLAKE3’s speed in action.
Let’s test it with two files:
- small.txt: A tiny text file (say, 1KB).
- large_file.bin: A 200MB binary file (you can create one with dd or download a sample).
Sample output:
Processing small.txt...
Size: 1024 bytes
Hash: abc123def4567890abc123def4567890abc123def4567890abc123def4567890
Time taken: 0.5ms
Processing large_file.bin...
Size: 209715200 bytes
Hash: 7890abc123def4567890abc123def4567890abc123def4567890abc123def456
Time taken: 2.8s
For the small file, BLAKE3 is lightning-fast—half a millisecond! For the 200MB file, it takes about 2.8 seconds, which is impressive for a cryptographic hash. The parallelism in BLAKE3 really shines here, as it can split the file into chunks and hash them concurrently on multi-core CPUs.
Custom Buffer Size: Taking Control
The default BufReader uses an 8KB buffer, which is fine for most cases. But what if you want to tweak the buffer size for performance or memory constraints? Let’s write a version of the file-hashing function that uses a custom buffer.
use blake3;
use std::fs::File;
use std::io::{self, BufReader, Read};
use std::path::Path;
use std::time::{Duration, Instant};
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
fn hash_file_custom_buffer<P: AsRef<Path>>(path: P) -> Result<(u64, String, Duration)> {
let file = File::open(path.as_ref())?;
let metadata = file.metadata()?;
let file_size = metadata.len();
let mut reader = BufReader::new(file);
let mut hasher = blake3::Hasher::new();
let start = Instant::now();
// Custom buffer size: 4KB
let mut buffer = [0u8; 4096];
loop {
let bytes_read = reader.read(&mut buffer)?;
if bytes_read == 0 {
break;
}
hasher.update(&buffer[..bytes_read]);
}
let hash = hasher.finalize();
let hash_hex = hash.to_hex();
let duration = start.elapsed();
Ok((file_size, hash_hex, duration))
}
fn main() -> Result<()> {
let files = vec!["small.txt", "large_file.bin"];
for file in files {
if !Path::new(file).exists() {
println!("Skipping {} (does not exist)", file);
continue;
}
println!("Processing {}...", file);
match hash_file_custom_buffer(file) {
Ok((size, hash, duration)) => {
println!("Size: {} bytes", size);
println!("Hash: {}", hash);
println!("Time taken: {:?}", duration);
}
Err(e) => println!("Error hashing {}: {}", file, e),
}
println!();
}
Ok(())
}
Here, we manually read the file in 4KB chunks using a fixed buffer. The performance is usually similar to the default io::copy approach, but this gives you fine-grained control. For example, you might use a smaller buffer on memory-constrained devices or a larger one for high-performance servers.
Sample output:
Processing small.txt...
Size: 1024 bytes
Hash: abc123def4567890abc123def4567890abc123def4567890abc123def4567890
Time taken: 0.5ms
Processing large_file.bin...
Size: 209715200 bytes
Hash: 7890abc123def4567890abc123def4567890abc123def4567890abc123def456
Time taken: 2.9s
The timing is almost identical to the previous version, which shows that the default 8KB buffer is a good choice for most cases. But having the option to tweak it is nice when you need it.
Comparing BLAKE3 to SHA-256
So, how does BLAKE3 stack up against the industry standard, SHA-256? Let’s write a version of our file-hashing function using SHA-256 and compare the performance.
Add the sha2 crate to Cargo.toml:
[dependencies]
blake3 = "1.0"
sha2 = "0.10"
Here’s the SHA-256 version:
use sha2::{Digest, Sha256};
use std::fs::File;
use std::io::{self, BufReader};
use std::path::Path;
use std::time::{Duration, Instant};
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
fn hash_file_sha256<P: AsRef<Path>>(path: P) -> Result<(u64, String, Duration)> {
let file = File::open(path.as_ref())?;
let metadata = file.metadata()?;
let file_size = metadata.len();
let reader = BufReader::new(file);
let mut hasher = Sha256::new();
let start = Instant::now();
io::copy(&mut reader.take(u64::MAX), &mut hasher)?;
let hash = hasher.finalize();
let duration = start.elapsed();
let hash_hex = format!("{:x}", hash);
Ok((file_size, hash_hex, duration))
}
fn main() -> Result<()> {
let files = vec!["small.txt", "large_file.bin"];
for file in files {
if !Path::new(file).exists() {
println!("Skipping {} (does not exist)", file);
continue;
}
println!("Processing {}...", file);
match hash_file_sha256(file) {
Ok((size, hash, duration)) => {
println!("Size: {} bytes", size);
println!("Hash: {}", hash);
println!("Time taken: {:?}", duration);
}
Err(e) => println!("Error hashing {}: {}", file, e),
}
println!();
}
Ok(())
}
Run it, and compare the times:
Processing small.txt...
Size: 1024 bytes
Hash: a591a6d40bf420404a011733cfb7b190d62c65bf0bcda32b57b277d9ad9f146e
Time taken: 0.6ms
Processing large_file.bin...
Size: 209715200 bytes
Hash: e4c4d8f3bf76b692de791a173e05321150f7a345b46484fe427f6acc7ecc81be
Time taken: 6.2s
Now, let’s compare:
- Small file (1KB):
- BLAKE3: ~0.5ms
- SHA-256: ~0.6ms
- Winner: BLAKE3, but the difference is negligible for tiny files.
- Large file (200MB):
- BLAKE3: ~2.8s
- SHA-256: ~6.2s
- Winner: BLAKE3, by a landslide!
BLAKE3’s parallelism and optimized design make it way faster for large files. SHA-256 processes data sequentially, so it can’t keep up with BLAKE3’s multi-core magic. Even against SHA-512 (which is slower still in many cases), BLAKE3 comes out ahead.
Custom Hash Lengths with XOF
One of BLAKE3’s coolest features is its extendable-output function (XOF), which lets you generate hashes of any length. Need a 64-byte hash for extra security? A 16-byte hash for a compact signature? BLAKE3 can do it.
Here’s an example of generating a 64-byte hash:
use blake3;
use std::time::Instant;
fn main() {
let input = "hello world";
let mut hasher = blake3::Hasher::new();
let start = Instant::now();
hasher.update(input.as_bytes());
// Create a 64-byte buffer
let mut buffer = [0u8; 64];
hasher.finalize_xof().fill(&mut buffer);
let duration = start.elapsed();
// Encode as hex
let hash_hex = hex::encode(&buffer);
println!("Custom hash (64 bytes): {}", hash_hex);
println!("Hash length: {} bytes", buffer.len());
println!("Time taken: {:?}", duration);
}
Run it, and you’ll get:
Custom hash (64 bytes): d74981efa70a0c880b8d51c69f0c336e3f4d9e557ffef26505e5384eb29c6188...
Hash length: 64 bytes
Time taken: 510.9µs
The finalize_xof method turns the hasher into a streamable output, and fill lets you extract as many bytes as you want. This is incredibly flexible—whether you need a short hash for a quick checksum or a long one for a unique identifier, BLAKE3 has you covered.
Conclusion
After playing with BLAKE3 for a while, I’ve started moving most of my SHA-256 and SHA-512 logic over to it. Here’s why:
- Performance: BLAKE3 is consistently faster, especially for large files. That 2.8s vs. 6.2s for a 200MB file is a huge win in production.
- Flexibility: The XOF feature lets me tailor the hash length to my needs, which is something SHA can’t do.
- Parallelism: BLAKE3’s ability to split work across CPU cores makes it future-proof for multi-core systems.
- Ease of Use: The Rust crate is a joy to work with—clean APIs, great docs, and no weird gotchas.
- Security: It’s as secure as SHA-256 for most use cases, so I’m not sacrificing safety for speed.
Whether I’m hashing small strings for quick checksums, generating signatures for JWTs, or verifying massive files, BLAKE3 handles it all with ease. It’s like the Swiss Army knife of hashing algorithms.
BLAKE3 is a powerhouse in the world of cryptographic hashing, and its Rust implementation makes it a no-brainer for Rust programmers. From its blazing speed to its flexible output lengths, it’s a step above traditional algorithms like SHA-256 and SHA-512. Whether you’re building a file-sharing app, securing web tokens, or just playing around with cryptography, BLAKE3 is worth a look.
In this article, we’ve covered:
- Hashing simple strings and encoding them in hex, Base64, and Base58.
- Generating secure signatures with keyed hashing.
- Efficiently hashing small and large files with streaming.
- Tweaking buffer sizes for custom performance.
- Comparing BLAKE3 to SHA-256 (and winning).
- Using XOF for custom hash lengths.
I hope this has gotten you excited about BLAKE3! If you’re ready to dive in, grab the blake3 crate and start experimenting.
0 comments:
Post a Comment