# 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. ```rust 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 = 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: ```toml [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. ```rust 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 = CipherBox::read("correct horse battery staple", file)?; println!("logged in as {}", creds.username); Ok(()) } ``` ### Working with the contents A `CipherBox` dereferences to `T`, so you can call methods on the contents directly and modify them in place: ```rust let mut notes = CipherBox::new(Vec::::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: ```rust 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: ```rust #[derive(Serialize, Deserialize, Zeroize)] struct Credentials { username: String, api_token: String, #[serde(default)] server: Option, // 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. ```rust use cipherbox::{CipherBox, Error}; match CipherBox::::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](https://www.rfc-editor.org/rfc/rfc8949) 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. | 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 `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](https://unlicense.org). See [LICENSE](LICENSE) for details.