vitaminc-encrypt 0.8.1

Secure, flexible and fast encryption for Rust types. Part of the Vitamin-C cryptographic suite.
Documentation

Vitamin C Encrypt

Crates.io Workflow Status

Secure, flexible and fast encryption for Rust types.

This crate is part of the Vitamin C framework to make cryptography code healthy.

Features

  • Type-safe encryption: Encrypt and decrypt Rust types with compile-time safety
  • AES-256-GCM: Hardware-accelerated authenticated encryption — aws-lc-rs on native targets, RustCrypto's aes-gcm on wasm32. Both produce byte-identical ciphertext, so a value sealed in one environment opens cleanly in the other.
  • wasm32-unknown-unknown support: Builds and runs in browsers, Node.js, and edge runtimes (e.g. Supabase Edge Functions, Cloudflare Workers) with no C toolchain or feature flags
  • 256-bit keys only: Enforces quantum-resistant key sizes for future security
  • Protected types integration: Works seamlessly with vitaminc-protected for sensitive data handling
  • Additional Authenticated Data (AAD): Support for authenticated but unencrypted data
  • Automatic nonce generation: Secure random nonce generation for each encryption operation

Installation

Add this to your Cargo.toml:

[dependencies]
vitaminc-encrypt = "0.2.0-pre.1"
vitaminc-random = "0.2.0-pre.1"  # For key generation
vitaminc-aead = "0.2.0-pre.1"    # For the Encrypt and Decrypt derives

The Encrypt and Decrypt derives are re-exported here, but the code they generate names the vitaminc_aead crate, so a crate that derives them for its own types must depend on vitaminc-aead too.

Feature flags

  • hlist — static heterogeneous-list encryption support (enables vitaminc-aead's hlist feature)

Quick Start

use vitaminc_encrypt::{decrypt, encrypt, Aes256Cipher, Key};
use vitaminc_random::{SafeRand, SeedableRng, Generatable};

// Generate a key
let mut rng = SafeRand::from_entropy().expect("Failed to seed RNG");
let key = Key::random(&mut rng).expect("Failed to generate key");
let cipher = Aes256Cipher::new(&key).expect("Failed to create cipher");

// Encrypt a message under some associated data
let ciphertext = encrypt("secret message", &cipher, "user:42").expect("Failed to encrypt");

// Decrypt it back, naming the type to decrypt into
let plaintext: String = decrypt(&cipher, ciphertext, "user:42").expect("Failed to decrypt");
assert_eq!(plaintext, "secret message");

How it fits together

[Aes256Cipher] is the sink-based [Cipher] of ADR 0001: it seals and opens one tagged leaf under the AAD derived for the leaf's place in the value, and nothing else. Everything about the shape of a value — how a sequence's elements and a map's entries are bound to their positions, how an empty container is authenticated, the refusal of a container with nothing sealed — lives once in vitaminc-aead's encrypting sink and opening walk, and is driven by [encrypt] and [decrypt] (re-exported here). A value encrypted from static Rust therefore seals the same tagged leaves a value from a host binding does, and every ciphertext is self-describing.

The value going in is anything that implements [Encrypt], and the value coming out is anything that implements the sink-based [Decrypt]. Both are derivable.

Usage

Key Management

The [Key] type represents a 256-bit encryption key. Vitamin C only supports 256-bit keys to ensure quantum security and consistent behaviour across both backends.

Generating a Key

use vitaminc_encrypt::Key;
use vitaminc_random::{SafeRand, SeedableRng, Generatable};

let mut rng = SafeRand::from_entropy().expect("Failed to seed RNG");
let key = Key::random(&mut rng).expect("Failed to generate key");

Creating a Key from Bytes

use vitaminc_encrypt::Key;

let key_bytes = [0u8; 32];  // In practice, use securely generated bytes
let key = Key::from(key_bytes);

Encrypting Data

[encrypt] takes any [Encrypt] source. Built-in sources include:

  • String, &str, Vec<u8>, &[u8] and [u8; N]
  • every integer and float width, bool, and Option<T> where T: Encrypt
  • Vec<T>, HashMap<String, T> and BTreeMap<String, T> where T: Encrypt
  • Protected<T> where &T: Encrypt, walked without leaving the wrapper
  • chrono, rust_decimal and uuid scalars behind vitaminc-aead's features
use vitaminc_encrypt::{encrypt, Aes256Cipher, Key};
use vitaminc_random::{SafeRand, SeedableRng, Generatable};

let key = Key::random(&mut SafeRand::from_entropy().expect("Failed to seed RNG")).expect("Failed to generate key");
let cipher = Aes256Cipher::new(&key).expect("cipher creation failed");

// Encrypt a string
let ciphertext = encrypt("secret message", &cipher, ()).expect("encryption failed");

// Encrypt a collection (element-wise; each element is sealed under its own position)
let data = vec!["one".to_string(), "two".to_string()];
let ciphertext = encrypt(data, &cipher, ()).expect("encryption failed");

// Encrypt a fixed-size array, as one bytes leaf
let array = [0u8; 32];
let ciphertext = encrypt(array, &cipher, ()).expect("encryption failed");

Decrypting Data

[decrypt] opens a ciphertext into any type that implements [Decrypt], named at the call site:

use vitaminc_encrypt::{decrypt, encrypt, Aes256Cipher, Key};

let key = Key::from([0u8; 32]);
let cipher = Aes256Cipher::new(&key).expect("cipher creation failed");

// Decrypt to String
let ciphertext = encrypt("secret message", &cipher, ()).expect("encryption failed");
let plaintext: String = decrypt(&cipher, ciphertext, ()).expect("decryption failed");

// Decrypt a collection
let ciphertext = encrypt(vec!["one".to_string(), "two".to_string()], &cipher, ()).expect("encryption failed");
let strings: Vec<String> = decrypt(&cipher, ciphertext, ()).expect("decryption failed");

// Decrypt to a fixed-size array
let ciphertext = encrypt([0u8; 32], &cipher, ()).expect("encryption failed");
let array: [u8; 32] = decrypt(&cipher, ciphertext, ()).expect("decryption failed");

A ciphertext that does not have the shape the type expects — a string where a sequence was stored, a map missing a field — is refused as a [DecryptError] rather than decoded wrongly.

Upgrading from 0.5

The plaintext in every leaf now starts with a type tag, and the leaf is sealed under wire version 2. The Encrypt path of 0.5 sealed raw, untagged bytes under wire version 1, so [decrypt] refuses every version 1 leaf with [DecryptError::Cipher] rather than read the first byte of the value as a tag. To keep data written by 0.5, decrypt it with 0.5 and encrypt it again with this version.

Ciphertext from the 0.5 Value path (vitaminc-aead-value and the Go binding) was already tagged, and opens through [Aes256Cipher::opening_version_1]. Do not use that view for data from the Encrypt path: it can read such a leaf as a different value.

One-shot helpers

[encrypt_with_key] and [decrypt_with_key] build the cipher from a key for a single call with no associated data:

use vitaminc_encrypt::{decrypt_with_key, encrypt_with_key, Key};

let key = Key::from([0u8; 32]);
let ciphertext = encrypt_with_key(&key, "secret message").expect("encryption failed");
let plaintext: String = decrypt_with_key(&key, ciphertext).expect("decryption failed");
assert_eq!(plaintext, "secret message");

Additional Authenticated Data (AAD)

AAD allows you to authenticate additional context alongside the ciphertext without encrypting it. This is useful for binding metadata to encrypted data.

The third argument of [encrypt] and [decrypt] is the AAD. Any [IntoAad] type works — byte slices, byte arrays, String, or () for none.

use vitaminc_encrypt::{decrypt, encrypt, Aes256Cipher, Key};

let key = Key::from([0u8; 32]);
let cipher = Aes256Cipher::new(&key).expect("cipher creation failed");

// Encrypt with context
let user_id = b"user_123".as_slice();
let ciphertext = encrypt("secret message", &cipher, user_id).expect("encryption failed");

// Decrypt with the same context
let plaintext: String = decrypt(&cipher, ciphertext, user_id).expect("decryption failed");
assert_eq!(plaintext, "secret message");

AAD Must Match

Decryption will fail at the cipher if the AAD doesn't match:

use vitaminc_encrypt::{decrypt, encrypt, Aes256Cipher, DecryptError, Key};

let key = Key::from([0u8; 32]);
let cipher = Aes256Cipher::new(&key).expect("cipher creation failed");

// Encrypt with one context
let ciphertext = encrypt("secret", &cipher, b"context_1".as_slice()).expect("encryption failed");

// Try to decrypt with a different context - this will fail!
let result: Result<String, _> = decrypt(&cipher, ciphertext, b"context_2".as_slice());
assert!(matches!(result, Err(DecryptError::Cipher(_))));

Deprecated Aad alias

Aad remains available as a deprecated alias of [Context] during the transition. Existing imports continue to compile; use Context in new code.

#![allow(deprecated)]
use vitaminc_encrypt::{Aad, Context};

let aad: Aad<'static> = Aad::empty();
let context: Context<'static> = aad;
assert!(context.is_empty());

Working with Protected Types

Vitamin C Encrypt integrates with vitaminc-protected to ensure sensitive data is handled securely. A Protected<T> is walked by reference inside the wrapper, so the secret never leaves it on the way in, and comes back wrapped on the way out:

use vitaminc_protected::Protected;
use vitaminc_encrypt::{decrypt, encrypt, Aes256Cipher, Key};

let key = Key::from([0u8; 32]);
let cipher = Aes256Cipher::new(&key).expect("cipher creation failed");

// Encrypt protected data
let sensitive = Protected::new("password123".to_string());
let ciphertext = encrypt(sensitive, &cipher, ()).expect("encryption failed");

// Decrypt back to protected data
let decrypted: Protected<String> = decrypt(&cipher, ciphertext, ()).expect("decryption failed");

Encrypting Keys (Key Wrapping)

Keys can be encrypted with other keys, enabling key hierarchy and key wrapping. [Key::wrap] seals a key's protected 32 bytes under a key encryption key, and [Key::from_wrapped] reads them straight back into a Key, so the bytes never leave the wrapper in either direction. A Key is not an Encrypt source, so encrypt does not take one: a walk over a value hands its bytes to a sink, and any crate can write a sink.

use vitaminc_encrypt::{decrypt, encrypt, Aes256Cipher, Key};
use vitaminc_random::{SafeRand, SeedableRng, Generatable};

let mut rng = SafeRand::from_entropy().expect("Failed to seed RNG");

// Generate a key encryption key (KEK)
let kek = Key::random(&mut rng).expect("key generation failed");
let cipher = Aes256Cipher::new(&kek).expect("cipher creation failed");

// Generate a data encryption key (DEK)
let dek = Key::random(&mut rng).expect("key generation failed");

// Wrap the DEK with the KEK
let wrapped_dek = dek.wrap(&cipher, "dek:1").expect("encryption failed");

// Later, unwrap it straight back into a key and use it.
let dek = Key::from_wrapped(&cipher, wrapped_dek, "dek:1").expect("decryption failed");
let data_cipher = Aes256Cipher::new(&dek).expect("cipher creation failed");
# let ct = encrypt("payload", &data_cipher, ()).expect("encrypt");
# let back: String = decrypt(&data_cipher, ct, ()).expect("decrypt");
# assert_eq!(back, "payload");

Your Own Types

Derive [Encrypt] for a type to encrypt it, and the sink-based [Decrypt] to decrypt into it. Fields become map entries, each sealed under its own key; #[aead(passthrough)] stores a field in the clear, #[aead(skip)] leaves one out, and #[aead(rename = "...")] changes its wire key. See the vitaminc-aead documentation for the attributes.

use vitaminc_encrypt::{decrypt, encrypt, Aes256Cipher, Decrypt, Key, Encrypt};

#[derive(Debug, PartialEq, Encrypt, Decrypt)]
struct User {
    #[aead(passthrough)]
    id: u64,
    email: String,
}

let cipher = Aes256Cipher::new(&Key::from([0u8; 32])).expect("cipher creation failed");
let user = User { id: 42, email: "ada@example.com".into() };
let ciphertext = encrypt(User { id: 42, email: "ada@example.com".into() }, &cipher, "users").expect("encryption failed");
let back: User = decrypt(&cipher, ciphertext, "users").expect("decryption failed");
assert_eq!(back, user);

Security Features

AES-256-GCM

This crate uses AES-256-GCM (Galois/Counter Mode) which provides:

  • Confidentiality: Data is encrypted with AES-256
  • Authenticity: Built-in authentication prevents tampering
  • Performance: Hardware-accelerated on most modern CPUs

Cryptographic Backends

The AES-256-GCM implementation is selected at compile time based on the target architecture — there is no feature flag to choose between them, and downstream code uses the same Aes256Cipher API regardless.

Target Backend Notes
cfg(not(target_arch = "wasm32")) AWS-LC FIPS-validated, AES-NI accelerated. Maintained by Amazon Web Services.
cfg(target_arch = "wasm32") RustCrypto aes-gcm Pure Rust, no C toolchain. Required because aws-lc-rs's C deps don't cross-compile to wasm.

Both backends conform to RFC 5116 AES-256-GCM and produce byte-identical ciphertext for the same inputs. CI gates this with a Known-Answer Test that runs against all three configurations on every PR — native aws-lc-rs, native RustCrypto, and RustCrypto compiled to wasm32-unknown-unknown and executed in Node via wasm-pack test --node.

This means ciphertext written from a native server can be opened in a browser or edge runtime (and vice versa) without any compatibility shim.

256-bit Keys Only

Vitamin C enforces 256-bit keys to ensure:

  • Resistance to quantum computer attacks (via AES-256)
  • Consistent parameters across both the AWS-LC and RustCrypto backends
  • No risk of accidentally using weaker key sizes

Automatic Nonce Generation

Each encryption operation generates a unique random nonce, preventing nonce reuse which could compromise security. The nonce generator is a RefCell, so [Aes256Cipher] is !Sync: it is the synchronous cipher, used from one thread at a time.

Unspecified Errors

The cipher reports a failure as [Unspecified], which reveals no information: a wrong key, a wrong AAD and a tampered leaf are indistinguishable. The walk around it reports what it can say safely, such as a ciphertext whose shape does not match the type, through [EncryptError] and [DecryptError].

Best Practices

  1. Generate keys securely: Always use Key::random() with a cryptographically secure RNG
  2. Protect keys in memory: The Key type uses Protected internally to zeroize keys when dropped
  3. Use AAD for context: Include relevant context (user ID, record ID, etc.) as AAD to prevent ciphertext substitution
  4. Never reuse keys across environments: Use different keys for development, staging, and production
  5. Rotate keys regularly: Implement key rotation policies for long-lived systems
  6. Store keys securely: Use key management systems (KMS) for production deployments

Performance

Vitamin C Encrypt is designed for both security and performance:

  • Zero-copy operations where possible
  • Hardware acceleration via AWS-LC's AES-NI support on native targets
  • Minimal allocations during encryption/decryption

On wasm32-unknown-unknown the RustCrypto backend takes over and runs as pure Rust — slower than AES-NI but still constant-time and substantially faster than a JavaScript polyfill.

Error Handling

The cipher's own failures are the opaque [Unspecified]; the walk's are [EncryptError] and [DecryptError], which carry no plaintext.

use vitaminc_encrypt::{encrypt_with_key, EncryptError, Key, Unspecified};
use vitaminc_random::{SafeRand, SeedableRng, Generatable};

let key = Key::random(&mut SafeRand::from_entropy().expect("Failed to seed RNG")).expect("Failed to generate key");

match encrypt_with_key(&key, "message") {
    Ok(ciphertext) => println!("Encrypted successfully"),
    Err(EncryptError::Cipher(Unspecified)) => eprintln!("The cipher failed"),
    Err(other) => eprintln!("The value could not be encrypted: {other}"),
}

CipherStash

Vitamin C is brought to you by the team at CipherStash.

License: MIT