Pure Zig implementation of HCTR2, HCTR3, and their beyond-birthday-bound secure variants (CHCTR2, HCTR2-TwKD), plus format-preserving variants.
HCTR2 and HCTR3 are length-preserving tweakable wide-block encryption modes. CHCTR2 and HCTR2-TwKD are beyond-birthday-bound (BBB) secure variants that achieve approximately 85-bit security instead of HCTR2's 64-bit birthday-bound security. The format-preserving variants (HCTR2-FP and HCTR3-FP) are also length-preserving and additionally preserve character sets (e.g., decimal digits remain decimal).
These modes are designed for full-disk encryption, filename encryption, and other applications where nonces and authentication tags would be impractical.
HCTR2 and HCTR3 are modern tweakable encryption modes that provide the following properties:
- Length-preserving: ciphertext is the same length as plaintext (no expansion beyond a minimum length)
- Wide-block: changing any single bit of plaintext affects the entire ciphertext
- Tweakable: supports a public tweak parameter for domain separation
- No authentication tag or nonce required
- Built entirely from standard primitives (AES, Polyval, SHA-256)
These modes are particularly useful when you need encryption but cannot afford the overhead of nonces or authentication tags, such as encrypting fixed-size disk sectors, filenames, or database fields.
Use HCTR2 when you need:
- Fast, single-key encryption
- Good performance on modern hardware with AES-NI
- A simpler construction with fewer moving parts
- Compatibility with existing HCTR2 implementations
HCTR2 uses a single key and relies on Polyval for universal hashing and XCTR mode for the wide-block construction.
Use HCTR3 when you need:
- Commitment security (resistance to key-manipulation attacks)
- Protection in scenarios where encryption keys might be known or compromised (cloud storage, message franking)
- Collision-resistant tweak processing for stronger domain separation
- Applications requiring both confidentiality and commitment properties
HCTR3 derives two keys from the input key and uses SHA-256 to hash tweaks before processing, providing collision resistance in known-key scenarios. This prevents commitment attacks (CMT-4) that break HCTR2 when adversaries can manipulate keys. HCTR3 employs ELK (Encrypted LFSR Keystream) mode with constant-time LFSR implementation instead of XCTR, providing additional security margins in constrained environments.
Use CHCTR2 when you need:
- Beyond-birthday-bound security (~85-bit instead of ~64-bit)
- No restrictions on tweak usage
- Multi-user security guarantees
- Compatibility with NIST's BBB accordion mode requirements
CHCTR2 cascades HCTR2 twice with two independent keys, achieving 2n/3-bit multi-user security. The construction optimizes the middle hash layers: Z_{1,2} = H1(T,R) XOR H2(T,R). Cost is 2 block cipher calls + 3 field multiplications per block.
Reference: "Beyond-Birthday-Bound Security with HCTR2" (ASIACRYPT 2025)
Use HCTR2-TwKD when you need:
- Beyond-birthday-bound security with minimal overhead
- Same performance as standard HCTR2
- Tweak-based key derivation (each unique tweak derives a unique key)
- Applications where the same tweak is not reused excessively
HCTR2-TwKD derives a fresh HCTR2 key from each tweak using the CENC construction, achieving 2n/3-bit security when the number of encryptions per tweak is bounded by approximately 2^42. Cost per block is identical to HCTR2 (1 BC call + 2 field multiplications), with a small per-tweak overhead for key derivation.
Reference: "Beyond-Birthday-Bound Security with HCTR2" (ASIACRYPT 2025)
Use the format-preserving variants when you need:
- Encryption that preserves the character set (e.g., decimal digits remain decimal)
- Encrypted values in a specific radix (base-10, base-16, base-64, etc.)
- Filename encryption where certain characters are forbidden
- Database encryption where column types must be preserved
Like standard HCTR2/HCTR3, the format-preserving variants are length-preserving (no ciphertext expansion). They additionally maintain the character set by operating on digits in a specified radix. HCTR2-FP and HCTR3-FP support any radix from 2 to 256. Pre-configured variants are provided for common radixes:
- Decimal (radix 10): useful for credit cards, IDs, phone numbers
- Hexadecimal (radix 16): useful for hex-encoded data
- Base64 (radix 64): useful for URL-safe encryption
Note that format-preserving modes have higher minimum message lengths (e.g., 39 digits for decimal, 32 for hex, 22 for base64) compared to standard HCTR2/HCTR3 (16 bytes minimum).
The following table shows common radix values for different use cases:
| Radix | Alphabet | Use Cases | Notes |
|---|---|---|---|
| 2 | 01 |
Binary data, bit flags | Maximum length, minimal alphabet |
| 4 | ACGT or 0123 |
DNA sequences, quaternary data | Bioinformatics, compact binary |
| 8 | 0-7 |
Octal numbers | Unix file permissions, legacy systems |
| 10 | 0-9 |
Credit cards, phone numbers, numeric IDs | Pre-configured Human-readable numbers |
| 16 | 0-9A-F |
Hex strings, hashes, MAC addresses | Pre-configured Common in computing |
| 26 | A-Z |
Alphabetic codes, license keys | Case-insensitive text |
| 32 | A-Z2-7 |
Base32 (RFC 4648), TOTP keys | No ambiguous chars, 2FA tokens |
| 32 | 0-9A-HJKMNP-TV-Z |
Crockford Base32 | Human-friendly, excludes I,L,O,U |
| 36 | 0-9A-Z |
Short IDs, URL shorteners | Case-insensitive, compact |
| 58 | 1-9A-HJ-NP-Za-km-z |
Bitcoin/crypto addresses | No confusing chars (0,O,I,l removed) |
| 62 | 0-9A-Za-z |
URL shorteners, compact IDs | Case-sensitive, very compact |
| 63 | 0-9A-Za-z_ |
Programming identifiers | Alphanumeric + underscore |
| 64 | A-Za-z0-9+/ |
Base64 encoding, binary data | Pre-configured Standard Base64 |
| 64 | A-Za-z0-9-_ |
URL-safe Base64 | Web-safe variant, no padding |
| 66 | A-Za-z0-9-._~ |
URL unreserved chars (RFC 3986) | Safe for URLs without encoding |
| 85 | ASCII printable | Ascii85, binary encoding | Compact, printable characters |
| 91 | ASCII printable subset | Base91 | Very compact binary encoding |
| 95 | All printable ASCII | Full printable character set | Maximum compactness, may need escaping |
Filesystem-Safe Radixes:
- Radix 62-64: Safe across all major filesystems (Windows, Linux, macOS)
- Radix 66: URL unreserved characters, safe for both filenames and URLs
- Avoid characters:
/(Unix/Linux),\/:*?"<>|(Windows),:(macOS Finder)
Common Pre-configured Variants:
Hctr2Fp_128_Decimal/Hctr3Fp_128_Decimal: Radix 10Hctr2Fp_128_Hex/Hctr3Fp_128_Hex: Radix 16Hctr2Fp_128_Base64/Hctr3Fp_128_Base64: Radix 64- AES-256 variants also available (e.g.,
Hctr2Fp_256_Decimal)
You can create custom radix variants for any use case:
// Base-36 for case-insensitive alphanumeric identifiers
const Cipher36 = hctr2.Hctr2Fp(std.crypto.core.aes.Aes128, 36);
// Base-58 for cryptocurrency-style addresses
const Cipher58 = hctr2.Hctr3Fp(std.crypto.core.aes.Aes256, std.crypto.hash.sha2.Sha256, 58);
// Base-62 for compact URL shorteners
const Cipher62 = hctr2.Hctr2Fp(std.crypto.core.aes.Aes128, 62);Add to your build.zig.zon:
.dependencies = .{
.hctr2 = .{
.url = "https://github.com/jedisct1/zig-hctr2/archive/refs/tags/v0.1.6.tar.gz",
.hash = "...",
},
},Then in your build.zig:
const hctr2 = b.dependency("hctr2", .{
.target = target,
.optimize = optimize,
});
exe.root_module.addImport("hctr2", hctr2.module("hctr2"));const std = @import("std");
const hctr2 = @import("hctr2");
pub fn main() !void {
// Initialize cipher with a 128-bit key
const key: [16]u8 = @splat(0x00);
const cipher = hctr2.Hctr2_128.init(key);
// Encrypt a message
const plaintext = "Hello, World!!!!"; // Minimum 16 bytes
const tweak = "sector-42";
var ciphertext: [plaintext.len]u8 = undefined;
try cipher.encrypt(&ciphertext, plaintext, tweak);
// Decrypt the message
var decrypted: [plaintext.len]u8 = undefined;
try cipher.decrypt(&decrypted, &ciphertext, tweak);
}const hctr2 = @import("hctr2");
pub fn main() !void {
// Initialize with AES-256
const key: [32]u8 = @splat(0x00);
const cipher = hctr2.Hctr3_256.init(key);
const plaintext = "Sensitive data here!";
const tweak = "database-record-123";
var ciphertext: [plaintext.len]u8 = undefined;
try cipher.encrypt(&ciphertext, plaintext, tweak);
try cipher.decrypt(&plaintext_out, &ciphertext, tweak);
}const hctr2 = @import("hctr2");
pub fn main() !void {
// CHCTR2 requires two keys (combined into one 32-byte key for AES-128)
const key: [32]u8 = @splat(0x00); // K1 || K2
var cipher = hctr2.Chctr2_128.init(key);
const plaintext = "BBB-secure data!";
const tweak = "any-tweak-value";
var ciphertext: [plaintext.len]u8 = undefined;
try cipher.encrypt(&ciphertext, plaintext, tweak);
var decrypted: [plaintext.len]u8 = undefined;
try cipher.decrypt(&decrypted, &ciphertext, tweak);
// Or initialize with separate keys:
const key1: [16]u8 = @splat(0x01);
const key2: [16]u8 = @splat(0x02);
var cipher2 = hctr2.Chctr2_128.initSplit(key1, key2);
}const hctr2 = @import("hctr2");
pub fn main() !void {
// Master key for key derivation
const master_key: [16]u8 = @splat(0x00);
const cipher = hctr2.Hctr2TwKD_128.init(master_key);
const plaintext = "Sector data here";
const tweak = "sector-42"; // Max 14 bytes for KDF tweak
var ciphertext: [plaintext.len]u8 = undefined;
// Each unique tweak derives a unique HCTR2 key
try cipher.encrypt(&ciphertext, plaintext, tweak);
var decrypted: [plaintext.len]u8 = undefined;
try cipher.decrypt(&decrypted, &ciphertext, tweak);
// For longer tweaks, use split mode:
const kdf_tweak = "short-part"; // Used for key derivation (max 14 bytes)
const hctr2_tweak = "longer-tweak-passed-to-hctr2"; // Any length
try cipher.encryptSplit(&ciphertext, plaintext, kdf_tweak, hctr2_tweak);
}const hctr2 = @import("hctr2");
pub fn main() !void {
const key: [16]u8 = @splat(0x00);
const cipher = hctr2.Hctr2Fp_128_Decimal.init(key);
// Encrypt a credit card number (all digits remain digits)
const plaintext = "1234567890123456789012345678901234567890"; // Min 39 digits for decimal
const tweak = "user-cc-field";
var ciphertext: [plaintext.len]u8 = undefined;
try cipher.encrypt(&ciphertext, plaintext, tweak);
// ciphertext contains only decimal digits
var decrypted: [plaintext.len]u8 = undefined;
try cipher.decrypt(&decrypted, &ciphertext, tweak);
}const hctr2 = @import("hctr2");
const std = @import("std");
pub fn main() !void {
// Create a base-36 cipher (0-9, a-z)
const Cipher = hctr2.Hctr2Fp(std.crypto.core.aes.Aes128, 36);
const key: [16]u8 = @splat(0x00);
const cipher = Cipher.init(key);
// Minimum length depends on radix
const min_len = Cipher.first_block_length;
// ... use cipher
}HCTR2 and HCTR3 provide confidentiality only, not authenticity. They do not detect tampering or forgery. If your threat model includes active attackers who can modify ciphertexts, you need additional authentication (e.g., HMAC, digital signatures) or should use an authenticated encryption mode like AES-GCM instead.
- HCTR2/HCTR3: 16 bytes minimum
- HCTR2-FP/HCTR3-FP: depends on radix (e.g., 39 digits for radix-10, 32 digits for radix-16, 22 digits for radix-64)
Messages shorter than the minimum will return error.InputTooShort.
Important: Format-preserving modes are length-preserving (no expansion). Input length in digits equals output length in digits.
In HCTR2-FP and HCTR3-FP, the first ciphertext block uses base-radix encoding, which may produce statistically distinguishable patterns. For example, in base-10 (decimal), the distribution of first-block digits may not appear uniformly random. However, the underlying encrypted data remains cryptographically secure—the encoding bias does not leak information about the plaintext.
Standard key management practices apply:
- Use cryptographically secure random number generators for key generation
- Store keys securely (e.g., hardware security modules, encrypted key stores)
- Implement proper key rotation policies
- Never hardcode keys in source code
Both HCTR2 and HCTR3 are designed to leverage AES-NI instructions on modern processors. Performance characteristics:
- HCTR2 is slightly faster due to simpler construction
- HCTR3 has higher security margins but slightly more overhead
- Format-preserving modes have additional computational cost from radix conversion
- Both scale well with message size (wide-block encryption is parallelized)
Run zig build bench -Doptimize=ReleaseFast to measure performance on your hardware.
- Length-preserving encryption with HCTR2 - Paul Crowley, Nathan Huckleberry, Eric Biggers (IACR ePrint Archive)
- HCTR3 - NIST SP 800-197 Workshop presentation
- Beyond-Birthday-Bound Security with HCTR2 - Chen, Y.L., et al. (ASIACRYPT 2025, LNCS 16245, pp. 3-34)