Bastion — MLSigcrypt-v3
Bastion is a Rust crate implementing MLSigcrypt-v3, an experimental post-quantum signcryption scheme. It combines confidentiality, integrity, and sender authenticity into a single packet operation built on top of ML-DSA-87 (FIPS 204) lattice arithmetic.
Status: Experimental. The construction has not been formally proven secure. It is suitable for research, prototyping, and internal evaluation. It is not recommended for production systems handling sensitive data until a formal security proof is available. See OPEN_PROBLEMS.md for a detailed account of what remains unresolved.
What This Is
MLSigcrypt-v3 is a signcryption scheme — a primitive that simultaneously encrypts a message and authenticates its sender, producing a single packet that can only be opened by the intended recipient and carries a verifiable sender identity.
The scheme is described in three levels:
- Level 1: ML-KEM-1024 + ML-DSA-87 + SHAKE-256 (two-sponge design). Closest to standard composable primitives. Not FIPS 140-3 validated as a composition, but uses standardised FIPS 203/204 components.
- Level 2: Level 1 with a shared lattice matrix between the KEM and DSA components, reducing redundant NTT work during key generation.
- Level 3 (this codebase): Algebraic signcryption. The separate ML-KEM encapsulation
is replaced by a Regev-style encapsulation driven by the same mask vector
yused in the ML-DSA signing loop. This halves the number of large NTT pipelines during signcrypt and unsigncrypt.
This repository implements Level 3 only.
What This Is Not
- Not a drop-in replacement for TLS, Signal, or any FIPS-validated scheme. This is a research-grade primitive at an early maturity level.
- Not formally proven secure. The construction is plausible and the implementation is hardened, but the security proof reducing confidentiality and authenticity to Module-LWE and Module-SIS respectively has not been written or peer-reviewed.
- Not FIPS 140-3 compliant. The underlying ML-DSA-87 parameters are reused, but the overall packet construction is custom and not validated.
- Not compatible with Level 1 or Level 2 keys or packets.
Public API
The crate exposes exactly three public cryptographic operations and three sizing constants. Nothing else is public.
;
;
;
pub const MLSIGCRYPT_PUBLIC_KEY_SIZE: usize; // 5600 bytes
pub const MLSIGCRYPT_SECRET_KEY_SIZE: usize; // 13024 bytes
pub const MLSIGCRYPT_PACKET_OVERHEAD: usize; // 8393 bytes (fixed per-packet cost)
All operations take caller-provided output buffers. No heap allocation occurs in the hot path. All operations return a unified error string on failure with no implementation detail.
Quick Start
Add to Cargo.toml:
[]
= "0.8.0"
use ;
let aad = b"session-context";
let msg = b"hello from alice";
let mut sender_pk = ;
let mut sender_sk = ;
let mut recipient_pk = ;
let mut recipient_sk = ;
mlsigcrypt_keygen?;
mlsigcrypt_keygen?;
let mut packet = vec!;
let packet_len =
mlsigcrypt_signcrypt?;
let mut plaintext = vec!;
let plain_len = mlsigcrypt_unsigncrypt?;
assert_eq!;
Packet Format
Every packet has a fixed-overhead prefix followed by a variable-length ciphertext:
[13 bytes] alg_id = "MLSigcrypt-v3" (ASCII, no null terminator)
[1 byte] version = 0x03
[32 bytes] key_id_S (sender key identifier)
[32 bytes] key_id_R (recipient key identifier)
[3680 bytes] encap = u ‖ v (algebraic encapsulation of the message key)
[4480 bytes] z (ML-DSA response vector, l=7 polynomials)
[64 bytes] c̃ (challenge digest)
[83 bytes] h (hint bits for high-bit reconstruction)
[8 bytes] ct_len (ciphertext length, big-endian u64)
[N bytes] ct (SHAKE-256 keystream XOR plaintext)
─────────────────────────────
Fixed overhead: 8393 bytes
The overhead is larger than Level 1 (6281 bytes) and Level 2 (6281 bytes) because
the encapsulation u ‖ v uses exact 23-bit coefficient encoding (5 × 736 bytes =
3680 bytes) rather than ML-KEM's compressed 11-bit encoding. A compressed encoding
is planned for a future revision once the security proof is in place.
Key Hierarchy
Each identity is derived deterministically from a 32-byte master secret:
msk (32 bytes, uniform random from OS)
│
├─ SHA3-512("MLSigcrypt-v3/matrix_seed" ‖ msk)[0..32] → matrix_seed
│ └─ SHAKE-128(matrix_seed)[0..32] → ρ_shared
│ └─ expand_a(ρ_shared) → A (shared 8×8 matrix)
│
├─ SHA3-512("MLSigcrypt-v3/kem_seed" ‖ msk)[0..32] → sk_enc_seed
│ └─ A · s + e (s, e derived from sk_enc_seed) → pk_enc = t_R
│
└─ SHA3-512("MLSigcrypt-v3/sig_seed" ‖ msk)[0..32] → sig_seed
└─ ML-DSA-87.KeyGen(sig_seed, A) → (sk_sig, pk_sig = t_S)
key_id = SHA3-512("MLSigcrypt-v3/key_id" ‖ pk_enc ‖ pk_sig ‖ ρ_shared)[0..32]
The shared matrix A is generated once per identity and used for both the
encapsulation key and the signing key. This is the core optimisation introduced in
Level 2 and retained in Level 3.
Key sizes:
| Component | Size |
|---|---|
| Public key | 5600 bytes (32 key_id + 32 ρ_shared + 2944 pk_enc + 2592 pk_sig) |
| Secret key | 13024 bytes (32 matrix_seed + 32 sk_enc_seed + 4896 sk_sig + 5600 pk embedded) |
Signcrypt Algorithm (Level 3)
At a high level, signcrypt performs the following steps:
- Validate both sender and recipient keys for internal consistency.
- Derive AAD digest:
aad_digest = SHA3-512("MLSigcrypt-v3/aad\x03" ‖ aad). - Derive signing randomness:
ρ' = SHAKE256(k_seed ‖ rnd ‖ aad_digest ‖ key_id_S ‖ key_id_R), whererndis 32 fresh bytes from the OS (hedged signing). - Sample message key
mkeyfrom the OS (32 bytes independent ofy). - Rejection-sampling loop:
- Sample mask
yfromρ'and counterκ. - Compute
w = A · y; decompose into high bitsw₁and low bitsw₀. - Compute the algebraic encapsulation: derive
r, e₁, e₂fromSHAKE256(ENCAP_MASK_DOMAIN ‖ packed_y), thenu = Aᵀ · r + e₁,v = tᵣᵀ · r + e₂ + encode(mkey). Encode asencap = u ‖ v. - Encrypt the plaintext using
S_E = SHAKE256("MLSigcrypt-v3/enc\x03" ‖ mkey ‖ key_id_S ‖ key_id_R ‖ encap). - Compute challenge:
c̃ = SHAKE256(DOMAIN_CHAL ‖ w₁_packed ‖ encap ‖ aad_digest ‖ pk_sig_S ‖ pk_enc_R ‖ ct_len ‖ ct). - Compute response
z = y + c · s₁and hinth. Reject if norm bounds are exceeded or hint weight exceedsω.
- Sample mask
- Write packet in the layout above.
- Zeroize all sensitive intermediates.
The encapsulation randomness r, e₁, e₂ is derived from y but is computationally
independent of y from the adversary's perspective (under the assumption that
SHAKE-256 behaves as a random oracle). This decoupling was introduced specifically
to avoid the security issue present in an earlier version where y was used
directly as the encapsulation vector.
Unsigncrypt Algorithm
- Parse and validate packet header fields using constant-time comparisons.
- Verify signature challenge: reconstruct
w' = A·z − c̃·t_S, apply hints, repackw₁', recompute challenge, compare in constant time. Reject if mismatch. - Decapsulate: recover
mkeyfromencap = u ‖ vusings_R: computev − sᵣᵀ · u ≈ encode(mkey), threshold-decode to recovermkey. - Decrypt: reconstruct
S_Efrommkey,key_id_S,key_id_R,encap; XOR keystream with ciphertext. - Zeroize all sensitive intermediates on both success and failure paths.
Signature verification is always completed before decapsulation. This ordering prevents unauthenticated decryption oracles.
Performance
The following figures are from the write_results harness on a developer machine.
They should be treated as indicative rather than definitive; results vary with
hardware, OS scheduling, and the rejection-sampling loop's geometric distribution.
| Operation | Approximate time | Notes |
|---|---|---|
| Key generation | < 1 ms | Floor = 0 ns (no padding) |
| Signcrypt | ~3–5 ms | Floor = 7 ms (padded to floor) |
| Unsigncrypt | ~1–2 ms | Floor = 1.5 ms (padded to floor) |
The performance improvement over Level 1/2 comes from eliminating the separate
ML-KEM encapsulation pipeline. The single shared matrix A computed during key
generation is reused across the signing and encapsulation paths.
Timing floors are applied at the public API boundary to reduce observable variance. They are not a formal constant-time guarantee — see SECURITY.md.
Repository Layout
src/
lib.rs — public API and timing floors
mlsigcrypt/
mod.rs — module root, public entry points
keys.rs — key types, derivation, encoding
params.rs — protocol constants, packet offsets
signcrypt.rs — signcrypt / unsigncrypt algorithms
kat.rs — known-answer test vectors
specs/
algebraic.rs — noisy algebraic encapsulation (u‖v)
keccak.rs — Keccak-f[1600], SHAKE-128/256 sponge
sha512.rs — SHA3-512 and SHA-512
ml/
mod.rs — ML-DSA-87 public API
params.rs — ML-DSA-87 parameter constants
field.rs — Z_q arithmetic (Montgomery, Barrett)
ntt.rs — 256-point NTT
poly.rs — Polynomial type
vec.rs — PolyVec<M> generic vector
matrix.rs — K×L polynomial matrix
sampling.rs — ExpandA, ExpandS, ExpandMask, SampleInBall
packing.rs — bit-packing for pk, sk, sig
keygen.rs — ML-DSA.KeyGen (FIPS 204 Algorithm 1)
sign.rs — ML-DSA.Sign (FIPS 204 Algorithm 2)
verify.rs — ML-DSA.Verify (FIPS 204 Algorithm 3)
constant_time.rs — constant-time comparison helpers
zeroize.rs — volatile-write zeroization
os_random.rs — OS entropy (no external crates)
error.rs — opaque error types
benches/public_api.rs — Criterion benchmarks
examples/
public_api_demo.rs — basic roundtrip demo
write_results.rs — allocation + timing spread report
fuzz/fuzz_targets/ — libFuzzer targets
Verification Workflow
# Format, check, lint
# Tests (includes known-answer and integration tests)
# Primitive vector gates
# Generate known-answer test vectors (prints hex intermediates)
# Allocation + timing spread report
# Benchmarks
# Fuzzing (requires nightly + cargo-fuzz)
&&
Dependencies
Runtime: none. The [dependencies] table is empty. All cryptographic
primitives are implemented from scratch within the crate.
Dev/test only: proptest, criterion, hex.
Minimum Rust Version
rust-version = "1.92" (required for edition = "2024" and const generics
features used in PolyVec<M>).
License
Licensed under MIT OR Apache-2.0.