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

(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

https://docs.rs/aes-gcm/latest/aes_gcm/
https://docs.rs/aes-gcm/latest/aes_gcm/

The Building Blocks

encryption & decryption

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 persistence
  • counter_key: The key used to identify the counter in the database
  • cipher: 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,
];

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.

cryptographic tools like AES-GCM require the nonce to be exactly 12 bytes

So Nonce::from_slice(nonce_bytes) is a helper function that:

  1. Checks that the slice is 12 bytes.
  2. 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.

decryption
Decrypting the encrypted value

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:

  1. Key Management: As mentioned, hardcoding the encryption key is not secure for production. Consider using a proper key management solution.
  2. Error Messages: The current error messages could potentially leak information about the system. In production, you might want to use more generic error messages.
  3. Database Security: The RocksDB files themselves should be protected at the file system level.
  4. 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.

demo showing the persistence of the stored counter value

Full code

 database to be embedded

encryption