Understanding Encrypted Counters in Rust with RocksDB
Need to tackle a problem like storing persistent counters, but with added security requirements? Let’s explore a practical implementation of an encrypted counter using Rust, RocksDB key value store for persistence, and AES-GCM for encryption. (We don’t want users to manipulate the data).
Sections
- Core Structure
- Initialization
- Security Implementation
- Nonce Generation
- Combined Storage Format
- Reading Data
- Counter Operations
- Setting a Value
- Incrementing
- Incrementing by a Specific Amount
- Practical Usage
(This code will take a while to compile due to the rocksdb crate, and may be ~176MB for the compiled debug binary, but when you optimize and compile for release it drops to ~7MB, so don’t worry)
This design is directly shaped by:
- The encryption algorithm’s requirements (nonce + ciphertext)
- The structure of key-value stores (single key → single value)
- The need for reliable, secure, and simple reads/writes

We want the database to be embedded, and the data file to be encrypted
We’ll use the AES-GCM crate to do the encryption – as you can see, it’s quite secure

The Building Blocks

The implementation combines several technologies:
- RocksDB: A high-performance key-value database, perfect for storing simple data like counters
- AES-GCM: A modern encryption algorithm that provides both confidentiality and integrity
- Rust’s strong type system: Ensuring memory safety and preventing common bugs
[dependencies]
rocksdb = "0.21.0"
aes-gcm = "0.10.1"
rand = "0.8.5"
Core Structure
Firstly, let’s analyse this implementation of EncryptedCounter
that provides a secure way to store and increment counter values.
The EncryptedCounter
struct contains three main components:
struct EncryptedCounter {
db: DB,
counter_key: Vec<u8>,
cipher: Aes256Gcm,
}
db
: The RocksDB instance for persistencecounter_key
: The key used to identify the counter in the databasecipher
: The AES-GCM cipher used for encryption/decryption
Initialization
The new
function initializes our counter with a given path and name:
pub fn new(db_path: &str, counter_name: &str) -> Result<Self, Box<dyn std::error::Error>> {
let mut opts = Options::default();
opts.create_if_missing(true);
let db = DB::open(&opts, Path::new(db_path))?;
let counter_key = counter_name.as_bytes().to_vec();
// Create cipher using the KeyInit trait
let cipher = Aes256Gcm::new_from_slice(&Self::KEY_BYTES)
.expect("Invalid key length");
let counter = Self {
db,
counter_key,
cipher,
};
// Only initialize if the counter doesn't exist or is corrupted
match counter.get() {
Ok(_) => {}, // Counter exists and is valid
Err(_) => {
// Counter doesn't exist or is corrupted, initialize it
counter.set(0)?;
}
}
Ok(counter)
}
Notice the automatic initialization to zero if the counter doesn’t already exist or if there’s an error reading it. This allows for a smoother developer experience.
Security Implementation
The security hinges on a few key elements:
1. Encryption Key
const KEY_BYTES: [u8; 32] = [
0x25, 0x19, 0x83, 0xee, 0x4f, 0x65, 0xb1, 0xc3,
0x7d, 0x9a, 0x6e, 0x5b, 0x0f, 0x4a, 0xcf, 0xd5,
0x3c, 0x2b, 0xdb, 0x37, 0x17, 0xb6, 0xac, 0x8e,
0x1f, 0x44, 0x0a, 0xd8, 0x6c, 0x55, 0x91, 0xe2,
];
This is a hardcoded encryption key. In a production environment, you would want to derive this from a password or secure key management system rather than embedding it in your code.
2. Nonce Generation
For each write operation, a fresh nonce (number used once) is generated:
// Generate a new nonce for each write
let mut nonce_bytes = [0u8; 12];
OsRng.fill(&mut nonce_bytes);
let nonce = Nonce::from_slice(&nonce_bytes);
This is critical for AES-GCM security – reusing a nonce with the same key would compromise the encryption.
*The fill
method from the OsRng
(Operating System Random Number Generator) is being used to populate this array with random bytes.fill()
is an efficient way to generate multiple random bytes at once by filling an existing buffer rather than generating each byte individually.
Why from_slice
?
In Rust, you can’t just treat a list of bytes (&[u8]
) as a fixed-size array like [u8; 12]
— because the compiler wants to be 100% sure it’s exactly the right size, and slices (&[u8]
) can be any length at runtime.

So Nonce::from_slice(nonce_bytes)
is a helper function that:
- Checks that the slice is 12 bytes.
- If yes, it turns it into a
Nonce
(a special wrapper around[u8; 12]
) so the cipher can use it safely.
This is a common safety pattern in Rust to avoid bugs or security issues caused by the wrong number of bytes being passed into sensitive code like encryption.
📌 from_slice
from_slice
is mostly used to safely convert a dynamically sized slice (&[u8]
) into a fixed-size structure required by cryptographic or low-level libraries.- It’s zero-copy: it returns a reference to a fixed-size array (not a copy).
- It panics or returns an error if the length is wrong (depending on the implementation).
3. Combined Storage Format
The encrypted data is stored with its nonce:
// Store nonce + encrypted data together
let mut data_to_store = Vec::with_capacity(12 + encrypted.len());
data_to_store.extend_from_slice(&nonce_bytes);
data_to_store.extend_from_slice(&encrypted);
This ensures that the correct nonce is always available for decryption.
Reading Data
When reading data back, the implementation first separates the nonce from the encrypted value:
// 1. Retrieve the encrypted data from the database
// 2. Split the first 12 bytes as the nonce, and the rest as the encrypted u64 value
// 3. Decrypt the encrypted value using the nonce and the cipher
// 4. Ensure the decrypted result is exactly 8 bytes (size of a u64)
// 5. Convert the 8-byte array into a u64 using from_le_bytes
pub fn get(&self) -> Result<u64, Box<dyn std::error::Error>> {
match self.db.get(&self.counter_key)? {
Some(encrypted_data) => {
// The first 12 bytes are the nonce, the rest is the encrypted value
if encrypted_data.len() <= 12 {
// into() in this case is turning a string into a boxed error,
return Err("Corrupted data: too short".into());
}
let (nonce_bytes, encrypted_value) = encrypted_data.split_at(12);
let nonce = Nonce::from_slice(nonce_bytes);
// Decrypt the value
let decrypted = self.cipher.decrypt(nonce, encrypted_value)
.map_err(|_| "Decryption failed - data may be corrupted")?;
if decrypted.len() == 8 {
let mut bytes = [0u8; 8];
bytes.copy_from_slice(&decrypted);
Ok(u64::from_le_bytes(bytes))
} else {
Err("Corrupted data: wrong length after decryption".into())
}
},
None => Err("Counter not found".into()),
}
}
Notice the validation of decrypted data length – this helps catch corrupted or tampered data.

Counter Operations
The implementation provides several useful operations:
Setting a Value
pub fn set(&self, value: u64) -> Result<(), Box<dyn std::error::Error>> {
let value_bytes = value.to_le_bytes();
// Generate a new nonce for each write
let mut nonce_bytes = [0u8; 12];
OsRng.fill(&mut nonce_bytes);
let nonce = Nonce::from_slice(&nonce_bytes);
// Encrypt the value
let encrypted = self.cipher.encrypt(nonce, value_bytes.as_ref())
.map_err(|_| "Encryption failed")?;
// Store nonce + encrypted data together
let mut data_to_store = Vec::with_capacity(12 + encrypted.len());
data_to_store.extend_from_slice(&nonce_bytes);
data_to_store.extend_from_slice(&encrypted);
self.db.put(&self.counter_key, data_to_store)?;
Ok(())
}
Incrementing
pub fn increment(&self) -> Result<u64, Box<dyn std::error::Error>> {
let current = match self.get() {
Ok(value) => value,
Err(_) => {
// If there's an error reading, initialize to 0 first
self.set(0)?;
0
}
};
let new_value = current + 1;
self.set(new_value)?;
Ok(new_value)
}
Incrementing by a Specific Amount
pub fn increment_by(&self, amount: u64) -> Result<u64, Box<dyn std::error::Error>> {
let current = match self.get() {
Ok(value) => value,
Err(_) => {
// If there's an error reading, initialize to 0 first
self.set(0)?;
0
}
};
let new_value = current + amount;
self.set(new_value)?;
Ok(new_value)
}
Practical Usage
The main function demonstrates how to use the EncryptedCounter:
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Example usage
let counter = EncryptedCounter::new("./counter_db", "my_secure_counter")?;
println!("Current value: {}", counter.get()?);
println!("After increment: {}", counter.increment()?);
println!("After incrementing by 5: {}", counter.increment_by(5)?);
Ok(())
}
Security Considerations
While this implementation is solid for many use cases, there are several security considerations to keep in mind:
- Key Management: As mentioned, hardcoding the encryption key is not secure for production. Consider using a proper key management solution.
- Error Messages: The current error messages could potentially leak information about the system. In production, you might want to use more generic error messages.
- Database Security: The RocksDB files themselves should be protected at the file system level.
- Memory Safety: Even though Rust is memory-safe, encrypted data might remain in memory longer than necessary. Sensitive data should be zeroed out when no longer needed.
Performance Implications
Every read and write operation requires encryption or decryption, which adds some overhead. However, AES-GCM is relatively fast, and modern CPUs often include hardware acceleration for AES operations. For most applications, this overhead is negligible compared to the disk I/O operations.

AES-GCM
Conclusion
This encrypted counter implementation provides a good balance of security and usability. It leverages Rust’s strong typing and error handling to create a robust solution that prevents many common security issues.
This example demonstrates several important concepts:
- Using third-party crates for databases and cryptography
- Proper error handling with the Result type
- Combining multiple security features for defense-in-depth
- Working with binary data and byte representations
By understanding this implementation, you’ll be well-equipped to build secure persistence mechanisms for your own applications.
Full code

encryption