A pure Zig implementation of hash-based signatures using Poseidon2 and SHA3 with incomparable encodings. This library implements Generalized XMSS signatures based on the framework from this paper, with exact compatibility with the hash-sig Rust implementation. Features include PRF-based key derivation, epoch management, encoding randomness, and full Merkle tree storage. Poseidon2 targets the KoalaBear 31βbit field with Montgomery arithmetic (compatible with plonky3 constants).
- Exact Rust Implementation Match: Key structures, signatures, and API match hash-sig Rust implementation
- PRF-Based Key Derivation: Derives OTS keys on-demand from a 32-byte PRF key (not storing all keys)
- Epoch Management: Supports activation_epoch and num_active_epochs for flexible key lifetimes
- Encoding Randomness: Includes
rho
(encoding randomness) in signatures for security - Full Tree Storage: Stores complete Merkle tree structure (all 2047 nodes for 1024 leaves) in secret key
- Self-Contained Keys: Parameters stored in public and secret keys
- Poseidon2 (KoalaBear): Width=16, external_rounds=8, internal_rounds=20, sbox_degree=3, Montgomery arithmetic
- SHA3-256: NIST-standardized cryptographic hash
- Hypercube Parameters: 64 chains of length 8 (w=3) from hypercube-hashsig-parameters
- Binary Encoding: Incomparable binary encoding with randomness
- 128-bit Post-Quantum Security: Well-tested security level
- Flexible Lifetimes: 2^10 to 2^32 signatures per keypair
- Parallel Key Generation: Multi-threaded with atomic work queues (~3x speedup on 8-core M2)
- Pure Zig: Minimal dependencies, fully type-safe
- Comprehensive Tests: Unit and integration tests
- Installation
- Quick Start
- Programs
- Usage
- Configuration
- Architecture
- Performance
- Security Considerations
- API Reference
- Testing
- Contributing
- License
Add to your build.zig.zon
:
.{
.name = .my_project,
.version = "0.1.0",
.dependencies = .{
.@"hash-zig" = .{
.url = "https://github.com/ch4r10t33r/hash-zig/archive/refs/tags/v0.1.0.tar.gz",
.hash = "1220...", // Will be generated by zig build
},
},
}
In your build.zig
:
const hash_zig_dep = b.dependency("hash-zig", .{
.target = target,
.optimize = optimize,
});
exe.root_module.addImport("hash-zig", hash_zig_dep.module("hash-zig"));
git clone https://github.com/ch4r10t33r/hash-zig.git
cd hash-zig
zig build test
const std = @import("std");
const hash_zig = @import("hash-zig");
pub fn main() !void {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer arena.deinit();
const allocator = arena.allocator();
// Initialize with medium lifetime (2^16 signatures)
// Only 128-bit security is supported
const params = hash_zig.Parameters.initHypercube(.lifetime_2_16);
var sig_scheme = try hash_zig.HashSignature.init(allocator, params);
defer sig_scheme.deinit();
// Generate a random seed for key generation (32 bytes required)
var seed: [32]u8 = undefined;
std.crypto.random.bytes(&seed);
// Generate keypair from seed with epoch management
// Parameters: seed, activation_epoch, num_active_epochs (0 = use full lifetime)
var keypair = try sig_scheme.generateKeyPair(allocator, &seed, 0, 0);
defer keypair.deinit(allocator);
// Sign a message (epoch must be tracked by your application!)
const message = "Hello, hash-based signatures!";
const epoch: u64 = 0; // YOUR APP must track this and never reuse!
// RNG seed for encoding randomness (rho)
var rng_seed: [32]u8 = undefined;
std.crypto.random.bytes(&rng_seed);
var signature = try sig_scheme.sign(allocator, message, &keypair.secret_key, epoch, &rng_seed);
defer signature.deinit(allocator);
// Verify signature
const is_valid = try sig_scheme.verify(allocator, message, signature, &keypair.public_key);
std.debug.print("Signature valid: {}\n", .{is_valid});
}
The hash-zig library includes several built-in programs for demonstration, testing, and performance analysis:
Purpose: Demonstrates basic usage of the hash-zig library
Command: zig build example
or zig build run
Description: Shows how to generate keypairs, sign messages, and verify signatures using the Rust-compatible implementation. Includes timing measurements, displays key information (PRF key, tree structure, epoch management), and demonstrates encoding randomness. Perfect for understanding the library's core functionality.
Purpose: Comprehensive performance benchmarking
Command: zig build benchmark
Description: Runs standardized performance tests across different key lifetimes (2^10 and 2^16). Measures key generation (with on-demand key derivation from PRF), signing (with encoding randomness), and verification times with detailed metrics. Outputs results in CI-friendly format for automated testing. Uses the Rust-compatible implementation.
Purpose: Tests SIMD-optimized implementations
Command: zig build simd-benchmark
Description: Benchmarks SIMD-optimized versions of the hash-based signature scheme. Tests both 2^10 and 2^16 lifetimes with SIMD acceleration. Useful for measuring performance with vectorization. Uses the same Rust-compatible architecture as the standard implementation but with SIMD optimizations.
Purpose: Compare Standard vs SIMD implementations
Command: zig build compare
Description: Compares the performance and correctness of the standard (scalar) vs SIMD implementations. Both use identical Rust-compatible architecture (PRF-based key derivation, epoch management, encoding randomness) and hypercube parameters (64 chains Γ 8 length). Shows performance speedups from SIMD optimizations and verifies public key consistency.
# Build all executables
zig build
# Run specific programs
zig build example # Basic usage demo (Rust-compatible)
zig build benchmark # Standard benchmark
zig build simd-benchmark # SIMD benchmark
zig build compare # Compare Standard vs SIMD
All programs provide detailed timing information and can be used for:
- Development: Understanding library behavior and performance characteristics
- Testing: Verifying correct implementation and performance expectations
- Benchmarking: Comparing different implementations and optimizations
- CI/CD: Automated performance regression testing
const hash_zig = @import("hash-zig");
// Configure parameters with Poseidon2 (default)
// 128-bit security with hypercube parameters: 64 chains of length 8 (w=3)
const params = hash_zig.Parameters.initHypercube(.lifetime_2_16);
var sig = try hash_zig.HashSignature.init(allocator, params);
defer sig.deinit();
// Generate a random seed (32 bytes required)
var seed: [32]u8 = undefined;
std.crypto.random.bytes(&seed);
// Generate keys from seed with epoch management
// activation_epoch: 0 (start from beginning)
// num_active_epochs: 0 (use full lifetime)
var keypair = try sig.generateKeyPair(allocator, &seed, 0, 0);
defer keypair.deinit(allocator);
// Sign (YOU must track epochs and never reuse!)
const epoch: u64 = 0; // Track this in your app's database!
// Generate RNG seed for encoding randomness (rho)
var rng_seed: [32]u8 = undefined;
std.crypto.random.bytes(&rng_seed);
var signature = try sig.sign(allocator, "message", &keypair.secret_key, epoch, &rng_seed);
defer signature.deinit(allocator);
// Verify
const valid = try sig.verify(allocator, "message", signature, &keypair.public_key);
The generateKeyPair
function requires a 32-byte seed. You can use this for:
- Random key generation: Use
std.crypto.random.bytes(&seed)
- Deterministic key generation: Derive seed from a password/phrase using a KDF
- Key recovery: Store the seed securely to regenerate the same keypair
// Random key generation (default approach)
var random_seed: [32]u8 = undefined;
std.crypto.random.bytes(&random_seed);
var keypair = try sig.generateKeyPair(allocator, &random_seed, 0, 0);
// Deterministic key generation from a known seed
const deterministic_seed: [32]u8 = .{1} ** 32; // Use a proper KDF in production!
var keypair2 = try sig.generateKeyPair(allocator, &deterministic_seed, 0, 0);
// Same seed will always generate the same keypair
// Epoch management example
var keypair_limited = try sig.generateKeyPair(
allocator,
&seed,
100, // activation_epoch: first valid epoch is 100
50 // num_active_epochs: can sign epochs 100-149
);
// Initialize with SHA3-256 instead of Poseidon2
const params = hash_zig.Parameters.initWithSha3(.lifetime_2_16);
// Everything else works the same way
var sig = try hash_zig.HashSignature.init(allocator, params);
defer sig.deinit();
// Poseidon2 with hypercube parameters (default) - optimized for ZK proof systems
const params_p2 = hash_zig.Parameters.initHypercube(.lifetime_2_16);
// SHA3-256 - NIST standard
const params_sha3 = hash_zig.Parameters.initWithSha3(.lifetime_2_16);
// Default parameters (Poseidon2 with lifetime_2_16)
const params_default = hash_zig.Parameters.initDefault();
// lifetime_2_10: 2^10 = 1,024 signatures
const params_short = hash_zig.Parameters.initHypercube(.lifetime_2_10);
// lifetime_2_16: 2^16 = 65,536 signatures (default)
const params_medium = hash_zig.Parameters.initHypercube(.lifetime_2_16);
// lifetime_2_18: 2^18 = 262,144 signatures (for benchmarking against Rust impl)
const params_benchmark = hash_zig.Parameters.initHypercube(.lifetime_2_18);
// lifetime_2_20: 2^20 = 1,048,576 signatures
const params_long = hash_zig.Parameters.initHypercube(.lifetime_2_20);
// lifetime_2_28: 2^28 = 268,435,456 signatures
const params_very_long = hash_zig.Parameters.initHypercube(.lifetime_2_28);
// lifetime_2_32: 2^32 = 4,294,967,296 signatures
const params_extreme = hash_zig.Parameters.initHypercube(.lifetime_2_32);
Using hypercube parameters as specified in hypercube-hashsig-parameters:
Parameter | Value | Notes |
---|---|---|
Security Level | 128-bit | Post-quantum secure |
Hash Output | 32 bytes | 256-bit hash for 128-bit security |
Encoding | Binary | Incomparable binary encoding |
Winternitz w | 3 | Chain length 8 (2^3 = 8) |
Number of Chains | 64 | Hypercube parameter specification |
Chain Length | 8 | Winternitz parameter w=3 |
Poseidon2 Width | 16 | Rust-compatible (external_rounds=8, internal_rounds=20, sbox_degree=3) |
Note: These parameters use the hypercube configuration (64 chains of length 8) as specified in the hypercube-hashsig-parameters repository, with Rust-compatible Poseidon2 parameters for interoperability.
Function | Security | Output Size | Use Case |
---|---|---|---|
Poseidon2 | 128-bit | 32 bytes | ZK proofs, arithmetic circuits |
SHA3-256 | 128-bit | 32 bytes | NIST standard, general crypto |
Both hash functions provide 128-bit post-quantum security with 32-byte (256-bit) output.
Lifetime | Tree Height | Max Signatures | Memory Required* |
---|---|---|---|
lifetime_2_10 | 10 | 1,024 | ~32 KB |
lifetime_2_16 | 16 | 65,536 | ~2 MB |
lifetime_2_18 | 18 | 262,144 | ~8.4 MB |
lifetime_2_20 | 20 | 1,048,576 | ~33 MB |
lifetime_2_28 | 28 | 268,435,456 | ~8.6 GB |
lifetime_2_32 | 32 | 4,294,967,296 | ~137 GB |
*Memory estimates based on 32-byte hashes and cached leaves. Actual memory usage may vary.
hash-zig/
βββ src/
β βββ root.zig # Main module entry point
β βββ params.zig # Configuration and parameters
β βββ poseidon2/
β β βββ fields/
β β β βββ generic_montgomery.zig # Generic 31-bit Montgomery arithmetic
β β β βββ koalabear/montgomery.zig # KoalaBear field instance
β β βββ instances/koalabear16.zig # Width-16 Poseidon2 instance (plonky3 constants)
β β βββ poseidon2.zig # Rust-compatible Poseidon2 implementation
β β βββ generic_poseidon2.zig # Generic Poseidon2 constructor (Montgomery)
β βββ sha3.zig # SHA3 hash implementation
β βββ encoding.zig # Incomparable encodings
β βββ tweakable_hash.zig # Domain-separated hashing
β βββ winternitz.zig # Winternitz OTS
β βββ merkle.zig # Merkle tree construction
β βββ signature.zig # Main signature scheme
βββ examples/
β βββ basic_usage.zig
βββ test/
β βββ integration_test.zig
βββ build.zig
- Poseidon2: Arithmetic hash over KoalaBear (31βbit) with Montgomery reduction; constants match plonky3
- SHA3-256: NIST-standardized Keccak-based hash for general-purpose cryptography
- Winternitz OTS: One-time signature with 64 chains of length 8 (w=3) using hypercube parameters
- Merkle Tree: Full binary tree storage (all nodes) with authentication path generation
- Binary Encoding: Incomparable binary encoding with randomness (rho) for security
- PRF-Based Key Derivation: On-demand OTS key generation from 32-byte PRF key
- Epoch Management: Flexible key lifetime with activation_epoch and num_active_epochs support
- Parallel Key Generation: Multi-threaded with atomic job queues (~3x speedup on 8-core M2)
This implementation is benchmarked against the reference Rust implementation using the hash-sig-benchmarks suite.
The benchmark repository provides:
- Automated comparison: Side-by-side performance testing of Rust vs Zig implementations
- Fair parameters: Both implementations use identical hypercube parameters (w=3, 64 chains, Poseidon2)
- Statistical analysis: Multiple iterations with mean, median, and standard deviation
- Reproducible results: Standalone wrappers for each implementation ensure consistent testing
Benchmark the implementations yourself:
git clone https://github.com/ch4r10t33r/hash-sig-benchmarks.git
cd hash-sig-benchmarks
python3 benchmark.py 3 # Run 3 iterations
The benchmark suite automatically:
- Clones both hash-sig (Rust) and hash-zig (Zig)
- Builds standalone benchmark wrappers for each
- Runs key generation benchmarks with identical parameters
- Compares and reports performance differences
Measured on Apple M2 with Zig 0.14.1, using Poseidon2 hash and level_128 security:
Operation | Time | Notes |
---|---|---|
Key Generation | ~110 seconds | Parallel multi-threaded, generates 1024 leaves + full tree (2047 nodes) |
Sign | ~370 ms | Derives OTS keys from PRF, includes encoding randomness |
Verify | ~93 ms | Reconstructs leaf, verifies Merkle path |
Key Sizes (Matching Rust):
- Public Key: 32 bytes (Merkle root) + Parameters struct
- Secret Key: 32 bytes (PRF key) + Full tree (2047 nodes Γ 32 bytes) + Epoch info
- Signature: Auth path (10 Γ 32 bytes) + OTS hashes (64 Γ 32 bytes) + rho (32 bytes)
Performance Notes:
- Using hypercube parameters (64 chains of length 8, w=3) from hypercube-hashsig-parameters
- PRF-based key derivation: Keys derived on-demand during signing (not pre-stored)
- Full tree storage: All 2047 tree nodes stored for fast auth path generation
- Using Rust-compatible Poseidon2 (width=16) for interoperability
- Optimized with inline hints for field arithmetic operations (~23% improvement)
- Parallel key generation uses all available CPU cores automatically
- Falls back to sequential mode for small workloads (< 64 leaves)
- Speedup scales with CPU core count (tested on M2 with ~3x improvement over sequential)
- Three levels of parallelization: leaf generation, WOTS chains, and Merkle tree construction
- Fast verification (~25ms) thanks to shorter chain length (8 vs 256)
- Benchmark against Rust: Use hash-sig-benchmarks for head-to-head comparison
All projections based on Apple M2 Mac (8 cores) with parallel implementation - actual times will vary by hardware.
Lifetime | Signatures | Tree Height | Estimated Time* | Memory Required |
---|---|---|---|---|
lifetime_2_10 | 1,024 | 10 | ~7 min (measured on M2, hypercube parameters) | ~33 KB |
lifetime_2_16 | 65,536 | 16 | ~34 minutes | ~2.1 MB |
lifetime_2_18 | 262,144 | 18 | ~2.2 hours | ~8.4 MB |
lifetime_2_20 | 1,048,576 | 20 | ~9 hours | ~34 MB |
lifetime_2_28 | 268,435,456 | 28 | ~97 days | ~8.6 GB |
lifetime_2_32 | 4,294,967,296 | 32 | ~4.1 years | ~137 GB |
*Projected by linear scaling from M2 parallel measurements with hypercube parameters: (signatures / 1024) Γ 7 min. Key generation scales O(n) with number of signatures. Performance will vary based on CPU core count and speed.
Operation | Time | Complexity |
---|---|---|
Sign | ~50 ms | O(log n) - constant across lifetimes |
Verify | ~25 ms | O(log n) - constant across lifetimes |
Note: Signing and verification times remain nearly constant across all lifetimes because they only process the authentication path (length = tree height). Only key generation scales with the number of signatures.
- Key Generation: O(n) where n = 2^tree_height (generates all OTS keypairs and caches leaves)
- Signing: O(log n) with caching (generates OTS sig + retrieves auth path from cache)
- Verification: O(log n) (derives OTS public key + verifies Merkle path)
- Memory: O(n) for cached leaves (required for fast signing)
- Use appropriate lifetime for your use case
- Choose hash function based on requirements:
- Poseidon2 for ZK-proof systems
- SHA3 for NIST compliance and interoperability
- Batch key generation offline when possible
- Always persist signature state to prevent index reuse
- For maximum throughput use ReleaseFast, LTO, and run on CPUs with many cores
- Benchmark large lifetimes (β₯ 2^16) to leverage parallel scheduling best
- NEVER reuse a signature index - Each index must be used only once
- Your application MUST track which indices have been used
- Store the last used index persistently before generating each signature
- The library does not enforce this - it's your responsibility!
- Protect the secret key - Use secure storage (encrypted, HSM, etc.)
- Verify signatures properly - Always check return values
- Plan key rotation - Generate new keypair before exhausting signatures
- Post-quantum secure: Resistant to quantum attacks
- Stateful: Requires tracking used indices (application responsibility)
- Forward secure: Old signatures valid even if key compromised
- One-time per index: Each tree index used once only
Important: This library does NOT manage signature state. Your application MUST:
- Track the next available index - Start at 0, increment after each signature
- Persist state before signing - Save index to disk/database BEFORE calling
sign()
- Never reuse an index - Reusing an index can compromise security
- Handle crashes gracefully - Use atomic writes or write-ahead logging
Example state management pattern:
// Pseudo-code for safe state management
fn signMessage(db: *Database, sig_scheme: *HashSignature, message: []const u8, secret_key: []const u8) !Signature {
// 1. Get and increment index atomically
const index = try db.getAndIncrementIndex();
// 2. Persist the new index BEFORE signing
try db.saveIndex(index + 1);
try db.flush(); // Ensure it's on disk
// 3. Now safe to sign
return sig_scheme.sign(allocator, message, secret_key, index);
}
The implementation matches Rust's GeneralizedXMSSSignatureScheme
exactly:
// Public Key (matches Rust GeneralizedXMSSPublicKey)
pub const PublicKey = struct {
root: []u8, // Merkle root hash
parameter: Parameters, // Hash function parameters
};
// Secret Key (matches Rust GeneralizedXMSSSecretKey)
pub const SecretKey = struct {
prf_key: [32]u8, // PRF key for key derivation
tree: [][]u8, // Full Merkle tree (all nodes)
tree_height: u32,
parameter: Parameters, // Hash function parameters
activation_epoch: u64, // First valid epoch
num_active_epochs: u64, // Number of valid epochs
};
// Signature (matches Rust GeneralizedXMSSSignature)
pub const Signature = struct {
epoch: u64, // Signature epoch/index
auth_path: [][]u8, // Merkle authentication path
rho: [32]u8, // Encoding randomness
hashes: [][]u8, // OTS signature values
};
// Initialize signature scheme
var sig_scheme = try hash_zig.HashSignature.init(allocator, params);
defer sig_scheme.deinit();
// Generate keypair (Rust: key_gen)
// activation_epoch: first valid epoch (0 = start)
// num_active_epochs: number of epochs (0 = use full lifetime)
var keypair = try sig_scheme.generateKeyPair(
allocator,
&seed, // 32-byte seed
0, // activation_epoch
0 // num_active_epochs (0 = all)
);
defer keypair.deinit(allocator);
// Sign a message (Rust: sign)
var signature = try sig_scheme.sign(
allocator,
message, // Message to sign
&keypair.secret_key, // Secret key reference
epoch, // Epoch index (0 to lifetime-1)
&rng_seed // RNG seed for encoding randomness
);
defer signature.deinit(allocator);
// Verify signature (Rust: verify)
const is_valid = try sig_scheme.verify(
allocator,
message, // Message to verify
signature, // Signature to check
&keypair.public_key // Public key reference
);
// Poseidon2 with hypercube parameters (recommended)
const params = hash_zig.Parameters.initHypercube(.lifetime_2_16);
// SHA3-256
const params_sha3 = hash_zig.Parameters.initWithSha3(.lifetime_2_16);
// Default (Poseidon2, lifetime_2_16, 128-bit security)
const params_default = hash_zig.Parameters.initDefault();
pub const SecurityLevel = enum { level_128 }; // Only 128-bit supported
pub const HashFunction = enum { poseidon2, sha3 };
pub const KeyLifetime = enum {
lifetime_2_10, // 1,024 signatures
lifetime_2_16, // 65,536 signatures
lifetime_2_18, // 262,144 signatures
lifetime_2_20, // 1,048,576 signatures
lifetime_2_28, // 268,435,456 signatures
lifetime_2_32 // 4,294,967,296 signatures
};
pub const EncodingType = enum { binary }; // Only binary encoding supported
zig build test
zig build lint
Note: The linter (zlinter) is a dev-time tool for this repository. Consumers of hash-zig
do not need to depend on zlinter unless they want to run our lint target in their own CI.
zig build
zig build example
zig build docs
This will generate HTML documentation in zig-out/docs/
. Open zig-out/docs/index.html
in your browser to view the API documentation.
Poseidon2:
test "poseidon2 hashing" {
const allocator = std.testing.allocator;
const params = hash_zig.Parameters.initHypercube(.lifetime_2_16);
var hash = try hash_zig.TweakableHash.init(allocator, params);
defer hash.deinit();
const result = try hash.hash(allocator, "test data", 0);
defer allocator.free(result);
try std.testing.expect(result.len == 32);
}
SHA3:
test "sha3 hashing" {
const allocator = std.testing.allocator;
const params = hash_zig.Parameters.initWithSha3(.lifetime_2_16);
var hash = try hash_zig.TweakableHash.init(allocator, params);
defer hash.deinit();
const result = try hash.hash(allocator, "test data", 0);
defer allocator.free(result);
try std.testing.expect(result.len == 32); // SHA3-256
}
Comparison:
test "compare hash functions" {
const allocator = std.testing.allocator;
// Poseidon2
const params_p2 = hash_zig.Parameters.initHypercube(.lifetime_2_16);
var hash_p2 = try hash_zig.TweakableHash.init(allocator, params_p2);
defer hash_p2.deinit();
// SHA3-256
const params_sha3 = hash_zig.Parameters.initWithSha3(.lifetime_2_16);
var hash_sha3 = try hash_zig.TweakableHash.init(allocator, params_sha3);
defer hash_sha3.deinit();
const data = "test";
const h1 = try hash_p2.hash(allocator, data, 0);
defer allocator.free(h1);
const h2 = try hash_sha3.hash(allocator, data, 0);
defer allocator.free(h2);
// Different hash functions produce different outputs
try std.testing.expect(!std.mem.eql(u8, h1, h2));
}
Contributions welcome! Please:
- Fork the repository
- Create a feature branch
- Write tests for changes
- Ensure tests pass (
zig build test
) - Run linter (
zig build lint
) - Open a Pull Request
GitHub Actions automatically runs on pushes/PRs to main
, master
, or develop
:
- Linting using zlinter
- Tests on Ubuntu, macOS, Windows
- Uses Zig 0.14.1 (required for zlinter compatibility)
See .github/workflows/ci.yml
for details.
Note: The project currently requires Zig 0.14.1 because zlinter only supports the 0.14.x branch. Once zlinter adds support for Zig 0.15+, we'll update to the latest version.
- Large tree generation (2^28+) requires significant time and memory resources
- No hypertree optimization for very large lifetimes
- Performance benchmarks are hardware-specific (tested only on M2 Mac)
Apache License 2.0 - see LICENSE file.
- Inspired by Rust implementation
- Framework from hash-sig paper
- Poseidon2 spec from Poseidon2 paper
- Issues: GitHub Issues
- Discussions: GitHub Discussions