jedisct1/zig-hctr2
HCTR2 and HCTR3 length-preserving encryption algorithm for Zig.
Pure Zig implementation of HCTR2, HCTR3, and format-preserving variants.
HCTR2 and HCTR3 are length-preserving tweakable wide-block encryption modes. 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:
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:
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:
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 the format-preserving variants when you need:
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:
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:
/
(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 64Hctr2Fp_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 {
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.
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:
Both HCTR2 and HCTR3 are designed to leverage AES-NI instructions on modern processors. Performance characteristics:
Run zig build bench -Doptimize=ReleaseFast
to measure performance on your hardware.