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
Debugoutput never reveals the contents. - Works with your types. Store anything that implements serde’s
SerializeandDeserializealong withzeroize::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::Readand write to anystd::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:
| Error | Meaning |
|---|---|
Error::CommitmentMismatch | The password is wrong, or the key derivation settings, salt, or key commitment have been altered. |
Error::Authentication | The encrypted data failed authentication: it has been tampered with or corrupted. |
Error::InvalidSignature | The input is not CipherBox data. |
Error::UnsupportedVersion(_) | The data was written in a storage format this version of CipherBox does not understand. |
Error::UnsupportedKdfParameters | The 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:
- Serializes the value as CBOR into a buffer that wipes its old memory whenever it grows.
- Normalizes the password to Unicode NFC, as RFC 8265 recommends, so that the same password typed on different systems always opens the data.
- 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).
- 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.
- 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.
- Writes the result in the layout below. All integers are big-endian.
| Field | Size | Contents |
|---|---|---|
| Signature | 9 bytes | ASCII CIPHERBOX |
| Version | 1 byte | Storage format version, currently 1 |
| Argon2 memory | 4 bytes | Memory cost in KiB |
| Argon2 iterations | 4 bytes | Time cost |
| Argon2 parallelism | 4 bytes | Degree of parallelism |
| Salt | 16 bytes | Argon2 salt |
| Nonce | 12 bytes | ChaCha20-Poly1305 nonce |
| Key commitment | 32 bytes | BLAKE3-derived commitment |
| Length | 8 bytes | Ciphertext length |
| Ciphertext | variable | Encrypted 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
CipherBoxis 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’
SerializeandDeserializeimplementations, 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.