cipherbox

Symmetric encryption/decryption of serialized data

Clone
git clone https://git.highenergymagic.org/cipherbox.git
Files
browse the default branch
Default branch
master
Last commit
2026-09-15

README.md raw

CipherBox

CipherBox is a small Rust library for keeping a value encrypted at rest under a password. Put any serializable value in a CipherBox, write it to a file (or any other std::io::Write), and read it back later with the same password. Everything else — key derivation, salts, nonces, authentication, and wiping secrets from memory — is handled for you.

use cipherbox::CipherBox;

let secret = CipherBox::new(String::from("my secret message"));

let mut encrypted = Vec::new();
secret.write("my-password", &mut encrypted)?;

let restored: CipherBox<String> = CipherBox::read("my-password", encrypted.as_slice())?;
assert_eq!(restored.as_str(), "my secret message");

Features

  • Password-based encryption using Argon2id key derivation and ChaCha20-Poly1305 authenticated encryption.
  • Tamper detection. Any modification to the encrypted data or its header causes reading to fail rather than returning corrupted contents.
  • Key commitment. Each ciphertext is bound to exactly one key, so a wrong password is reliably rejected and a file cannot be crafted to decrypt to different contents under different passwords.
  • Memory hygiene. The box, derived keys, key derivation memory, and serialization buffers are all wiped before they are freed, and Debug output never reveals the contents.
  • Works with your types. Store anything that implements serde’s Serialize and Deserialize along with zeroize::Zeroize.
  • Built to stay readable. The storage format is versioned, records its own key derivation settings, and stores values as self-describing CBOR, so your types can change over time without stranding old data.
  • I/O agnostic. Read from any std::io::Read and write to any std::io::Write: files, buffers, sockets, and so on.

Installation

CipherBox is not published on crates.io. Add it to your Cargo.toml as a git dependency, along with serde and zeroize if you want to store your own types:

[dependencies]
cipherbox = { git = "https://git.highenergymagic.org/cipherbox.git" }
serde = { version = "1", features = ["derive"] }
zeroize = { version = "1", features = ["zeroize_derive"] }

CipherBox uses the Rust 2024 edition, so it requires Rust 1.85 or newer.

Usage

Storing your own types

Derive Serialize, Deserialize, and Zeroize on the type you want to protect. The Zeroize implementation is what lets CipherBox wipe the value from memory when the box is dropped.

use cipherbox::CipherBox;
use serde::{Deserialize, Serialize};
use zeroize::Zeroize;

#[derive(Serialize, Deserialize, Zeroize)]
struct Credentials {
    username: String,
    api_token: String,
}

fn main() -> Result<(), cipherbox::Error> {
    let creds = CipherBox::new(Credentials {
        username: String::from("alice"),
        api_token: String::from("hunter2"),
    });

    // Encrypt to a file.
    let file = std::fs::File::create("credentials.cb")?;
    creds.write("correct horse battery staple", file)?;

    // Decrypt it again.
    let file = std::fs::File::open("credentials.cb")?;
    let creds: CipherBox<Credentials> = CipherBox::read("correct horse battery staple", file)?;
    println!("logged in as {}", creds.username);

    Ok(())
}

Working with the contents

A CipherBox<T> dereferences to T, so you can call methods on the contents directly and modify them in place:

let mut notes = CipherBox::new(Vec::<String>::new());
notes.push(String::from("remember the milk"));
assert_eq!(notes.len(), 1);

write borrows the box, so you can keep using and modifying the contents after saving them, and save again whenever you like:

notes.write("my-password", std::fs::File::create("notes.cb")?)?;
notes.push(String::from("call the plumber"));
notes.write("my-password", std::fs::File::create("notes.cb")?)?;

The contents are wiped when the box is dropped. If you are done with the data once it is saved, drop the box right away rather than leaving the plaintext in memory.

Changing your types

Values are stored with their field names, so the types you store can evolve. Removed fields are ignored when old data is read, and new fields can be read from old data as long as serde has a default for them:

#[derive(Serialize, Deserialize, Zeroize)]
struct Credentials {
    username: String,
    api_token: String,
    #[serde(default)]
    server: Option<String>, // added after data was first written
}

Renaming a field or changing its type is a breaking change, just as it would be for any other serde format; #[serde(alias = "...")] can help with renames.

Handling errors

All fallible operations return cipherbox::Error. The cases you are most likely to want to handle are:

ErrorMeaning
Error::CommitmentMismatchThe password is wrong, or the key derivation settings, salt, or key commitment have been altered.
Error::AuthenticationThe encrypted data failed authentication: it has been tampered with or corrupted.
Error::InvalidSignatureThe input is not CipherBox data.
Error::UnsupportedVersion(_)The data was written in a storage format this version of CipherBox does not understand.
Error::UnsupportedKdfParametersThe data demands key derivation settings that are invalid or exceed CipherBox’s limits.
Error::IO(_)Reading or writing failed, including input that is truncated.
Error::Deserialization(_)The data decrypted, but does not deserialize as the requested type.

Error is marked #[non_exhaustive], so a match on it needs a catch-all arm.

use cipherbox::{CipherBox, Error};

match CipherBox::<String>::read(password, file) {
    Ok(secret) => use_secret(&secret),
    Err(Error::CommitmentMismatch) => eprintln!("wrong password"),
    Err(e) => eprintln!("could not open file: {e}"),
}

How it works

Each call to write does the following:

  1. Serializes the value as CBOR into a buffer that wipes its old memory whenever it grows.
  2. Normalizes the password to Unicode NFC, as RFC 8265 recommends, so that the same password typed on different systems always opens the data.
  3. Generates a random 16-byte salt and derives a 32-byte master secret from the password with Argon2id version 1.3 (currently 19 MiB memory, 2 iterations, 1 degree of parallelism).
  4. Derives two independent values from the master secret using BLAKE3 in key-derivation mode with distinct contexts: the ChaCha20-Poly1305 encryption key, and a public key commitment.
  5. Encrypts the serialized value under a random 12-byte nonce, with the entire header as associated data so no part of it can be altered undetected.
  6. Writes the result in the layout below. All integers are big-endian.
FieldSizeContents
Signature9 bytesASCII CIPHERBOX
Version1 byteStorage format version, currently 1
Argon2 memory4 bytesMemory cost in KiB
Argon2 iterations4 bytesTime cost
Argon2 parallelism4 bytesDegree of parallelism
Salt16 bytesArgon2 salt
Nonce12 bytesChaCha20-Poly1305 nonce
Key commitment32 bytesBLAKE3-derived commitment
Length8 bytesCiphertext length
CiphertextvariableEncrypted data and 16-byte tag

Reading reverses the process. Data in an unknown format version is rejected immediately. Because the Argon2 settings are stored with the data, future releases can strengthen them for new data while still reading old data. When reading, settings above 4 GiB of memory, 64 iterations, or 64 degrees of parallelism are rejected, so a crafted file cannot make CipherBox consume unbounded memory or time. The commitment derived from the supplied password is compared in constant time against the stored one before decryption is attempted. The ciphertext is read incrementally, so a corrupt or malicious length field cannot make CipherBox allocate more memory than the input actually contains.

The repository includes an encrypted test file in format version 1, which every release must be able to read.

Things to keep in mind

  • Your password is the security. Argon2id makes guessing expensive, but it cannot rescue a weak password. There is no recovery mechanism: if the password is lost, so is the data.
  • Contents are plaintext in memory while the box is open. CipherBox protects data at rest. While a CipherBox is alive, its contents sit unencrypted in your process’s memory, and anything you copy out of it is your responsibility to wipe.
  • Metadata is not hidden. The size of the encrypted data is visible to anyone who can see the file.
  • Wiping has limits. CipherBox wipes every buffer it controls, but it cannot wipe copies made by your own types’ Serialize and Deserialize implementations, or leftover data on the stack.
  • CipherBox has not been independently audited.

License

CipherBox is released into the public domain under the Unlicense. See LICENSE for details.