- Rust 100%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| .forgejo/workflows | ||
| .github/workflows | ||
| src | ||
| tests | ||
| .gitattributes | ||
| .gitignore | ||
| Cargo.lock | ||
| Cargo.toml | ||
| LICENSE-APACHE | ||
| LICENSE-MIT | ||
| README.md | ||
vaiexia-wire
AEAD record layer, handshake, and mimicry primitives for the VAIEXIA obfuscated transport.
Status: Phase 5a — Datagram primitives (DatagramMimicry, QuicMimic, CookieSecret, AwgParams). Socket and obfs layers are Phase 5b.
What is this?
vaiexia-wire provides the lowest-level cryptographic framing for the VAIEXIA obfuscated transport protocol. It is intentionally sockets-free and async-free: pure state machines over byte slices, designed to be embedded into any I/O layer.
Current scope (Phase 5a)
- AEAD record layer — ChaCha20-Poly1305 with explicit 64-bit counter headers and WireGuard-style sliding-window anti-replay (Phase 1, UDP substrate).
- Noise XK handshake —
Noise_XK_25519_ChaChaPoly_BLAKE2s3-message mutual-authentication exchange; fully in-memory, no sockets, no async. - Post-handshake Session — wraps
snow::TransportStatefor TCP/reliable-path transport AEAD after the handshake completes. - Typed errors —
WireErrorextended withNoise(String)andMimicry(String)variants. - Stream mimicry profiles —
MimicryProfiletrait +VanillaandAmneziaJunkprofiles (byte-stream framing). - Datagram mimicry —
DatagramMimicrytrait +PassthroughandQuicMimicprofiles (whole-datagram shaping for UDP). - Cookie DoS primitive —
CookieSecret: WG-style stateless handshake-flood defence (BLAKE2s-keyed, src-bound, rotating, constant-time verify). - AWG params —
AwgParams: shared junk vocabulary for obfs and vaiexia-server AWG tunnel provisioning, derived fromMimicryConfig.
Not yet in scope
- Socket/transport layer (
vaiexia-obfs, Phase 5b) vaiexia-coreframe codec integration (Phase 2b)- Rekey policy (seam is present; policy TBD)
- Real-QUIC/quinn stack (
TlsMimicover real QUIC — planned follow-up)
Record wire format
┌──────────────────────────┬──────────────────────────────────────────┐
│ counter (8 bytes, BE) │ ciphertext || Poly1305 tag (16 bytes) │
└──────────────────────────┴──────────────────────────────────────────┘
- Counter — 64-bit big-endian sequence number. Explicit in the header so records can be opened out-of-order and replay-checked.
- Nonce — derived deterministically as
[0x00; 4] || counter.to_be_bytes()(12 bytes total). Never reused because the counter is strictly monotonic and the sealer refuses to proceed atu64::MAX. - AAD — caller-supplied associated data authenticated but not encrypted (e.g., a session identifier).
Anti-replay semantics
The opener maintains a 1024-bit sliding window (WireGuard/DTLS style):
- Increasing counter — always accepted; window advances.
- Out-of-order within window — accepted if not already seen.
- Duplicate — rejected with
WireError::Replay(counter). - Too old (below window) — rejected with
WireError::Replay(counter).
Security note: authentication (AEAD tag check) happens before the replay window is updated. A forged record never mutates replay state.
API sketch
Record layer (UDP substrate / Phase 1)
use vaiexia_wire::record::{RecordKey, RecordSealer, RecordOpener};
let key = RecordKey::from_bytes([0u8; 32]);
let mut sealer = RecordSealer::new(key.clone());
let mut opener = RecordOpener::new(key);
let record = sealer.seal(b"hello", b"session-id")?;
let plain = opener.open(&record, b"session-id")?;
assert_eq!(plain, b"hello");
RecordKey implements Zeroize / ZeroizeOnDrop — key material is cleared from memory when dropped.
Handshake + Session (TCP path / Phase 2a)
use vaiexia_wire::keypair::generate_keypair;
use vaiexia_wire::handshake::Handshake;
// Out-of-band: server publishes its static public key.
let server = generate_keypair()?;
let client = generate_keypair()?;
// Initiator pins the server's static public key (XK "known" half).
let mut initiator = Handshake::initiator(&client.private, &server.public)?;
// Responder only needs its own private key.
let mut responder = Handshake::responder(&server.private)?;
// 3-message XK exchange — pass buffers between the two sides.
let m1 = initiator.write_message(b"")?; responder.read_message(&m1)?;
let m2 = responder.write_message(b"")?; initiator.read_message(&m2)?;
let m3 = initiator.write_message(b"")?; responder.read_message(&m3)?;
// Responder now knows the client's static public key (sent encrypted in m3).
assert_eq!(responder.remote_static().unwrap(), client.public);
// Upgrade to a transport Session.
let mut si = initiator.into_session()?;
let mut sr = responder.into_session()?;
let ct = si.encrypt(b"hello")?;
assert_eq!(sr.decrypt(&ct)?, b"hello");
XK trust model
| Party | Knows before handshake | Learns during handshake |
|---|---|---|
| Initiator | Server static public key (pinned) | — |
| Responder | Own private key only | Client static public key (encrypted, msg 3) |
- Server-pinning (analogous to WireGuard peers): the initiator hard-codes the expected server public key. A wrong key causes the handshake to fail — an active attacker cannot impersonate the server.
- Client identity hiding: the initiator's static key is transmitted encrypted in message 3, after an ephemeral shared secret is established. An observer cannot link handshake traffic to a client identity.
Sessionis the TCP/reliable path.RecordLayerremains for the future UDP substrate.
Mimicry profiles
The mimicry module provides byte-stream framing that shapes traffic to resist passive DPI fingerprinting. Profiles are configured out-of-band — as part of the server config alongside the Noise static public key. There is no in-band profile or parameter negotiation; an in-band selector would itself be a constant fingerprint.
Vanilla
Minimal framing for baseline use and testing.
Wire format per record:
[ len: u32 BE (4 bytes) ][ record (len bytes) ]
No magic header, no padding, no preamble junk. jitter returns Duration::ZERO.
AmneziaJunk
AWG-inspired framing; the AmneziaWG homage. Shapes traffic with a per-deployment magic prefix, length bucketing, preamble junk, and timing jitter.
Wire format per record:
[ magic: 4 bytes ][ len: u32 BE (4 bytes) ][ record (len bytes) ][ padding ]
| Knob | Config field | Effect |
|---|---|---|
| Magic header | magic_header: [u8; 4] |
4-byte per-deployment prefix. Every deployment uses a different value so there is no constant byte pattern. |
| Preamble junk | preamble_junk_len: u16 |
Random bytes emitted before the first real frame. Both peers pre-agree the length; no in-band field. |
| Bucket padding | pad_bucket: u16 |
Pads total frame wire length up to a multiple of this value. 0 = no padding. Padding length is computed from len + pad_bucket — never stored in-band. |
| Jitter | jitter_ms: (u32, u32) |
Inter-write delay range in milliseconds. Implemented in the obfs layer (Phase 3b). |
Stream-correctness invariants
| Invariant | Mechanism |
|---|---|
| Message boundaries | Deterministic: 4 (magic) + 4 (len) + len + padding bytes consumed per frame |
| Padding length | Derived from len + pad_bucket — no extra field, fully deterministic |
| Partial buffer | frame_in returns NeedMore without consuming anything |
| Wrong magic | frame_in returns Invalid — stream is unrecoverable |
Oversized len (> 16 MiB) |
frame_in returns Invalid |
| Panic freedom | frame_in never panics on arbitrary bytes |
| Chunk-boundary reassembly | Verified by property tests across arbitrary chunk sizes |
Pre-shared config, no in-band negotiation
Profile and all parameters are part of the server deployment config. The channel that distributes the Noise static public key also distributes MimicryConfig. Changing the magic header or pad bucket requires re-deploying config to both endpoints — same operational model as rotating a WireGuard pre-shared key.
Scope and limitations
- True per-connection random junk lengths (AWG
Jc/Jmin/Jmaxstyle) are a UDP-substrate refinement deferred to Phase 5. Over a TCP byte-stream, the receiver must derive padding deterministically — random per-frame padding would require an in-band field, which adds a detectable wire pattern. - Real-world DPI testing is mandatory before claiming DPI-resistance. Lab-green does not equal field-green against GFW-class or Iran-class censors.
Datagram mimicry (Phase 5a)
The mimicry::datagram module provides whole-datagram shaping for UDP substrates. Unlike the stream MimicryProfile (which accumulates bytes and searches for frame boundaries), each datagram is already a complete message with a known length — no buffering, no NeedMore.
use vaiexia_wire::mimicry::{DatagramMimicry, QuicMimic, MimicryConfig};
use rand::thread_rng;
let config = MimicryConfig {
magic_header: [0xCA, 0xFE, 0xBA, 0xBE],
pad_bucket: 64,
preamble_junk_len: 0,
jitter_ms: (5, 25),
};
let profile = QuicMimic::new(config);
let mut rng = thread_rng();
// Sender: wrap an encrypted record into a datagram.
let mut datagram = Vec::new();
profile.shape_out(b"encrypted record", &mut datagram, &mut rng);
// Receiver: unwrap.
if let Some(record) = profile.shape_in(&datagram) {
// process record
}
Passthrough
shape_out copies the record verbatim into the datagram. shape_in returns the whole datagram unchanged (or None for an empty datagram). No padding is applied — a bare record starts with the Phase-1 [counter u64 BE] header and carries no length prefix, so the receiver cannot strip trailing padding without an explicit length field.
QuicMimic
Wraps each record in a fake QUIC long-header datagram:
[ byte0: 0b11xx_xxxx (top 2 = QUIC long-header marker; bits 4-5 = our tag) ]
[ pseudo_version_connid: 4 bytes derived from magic_header ]
[ inner_len: u16 BE ]
[ record: inner_len bytes ]
[ padding: up to pad_bucket multiple, random fill ]
shape_in validates: top-2-bit marker, our 2-bit tag, the 4-byte pseudo connection-id (per-deployment from magic_header), and bounds-checks header+inner_len ≤ datagram.len(). Returns None on any mismatch — panic-free.
Honest scope caveat: QuicMimic is header mimicry only — it produces bytes that superficially resemble a QUIC long-header datagram. It will not fool a stateful DPI classifier that performs version negotiation, connection-ID tracking, or QUIC handshake validation. A real QUIC stack integration (quinn-based TlsMimicQuic) is a planned follow-up. Real-world DPI-lab testing is required before deployment — same caveat as the stream profiles.
Cookie DoS primitive (Phase 5a)
CookieSecret provides WireGuard-style stateless handshake-flood DoS mitigation. Under load the responder issues a cheap cookie challenge instead of allocating Noise state; the legitimate initiator echoes the cookie in a retry.
use vaiexia_wire::cookie::CookieSecret;
let seed = [0xAA; 32];
let mut secret = CookieSecret::new(seed);
// Issue a cookie challenge to a peer at src_bytes (e.g. IP || port).
let src = b"192.168.1.1:12345";
let cookie = secret.make(src);
// Verify the echoed cookie (accepts current OR previous epoch).
assert!(secret.verify(src, &cookie));
// Rotate secret periodically (e.g. every 120 s).
let new_seed = [0xBB; 32];
secret.rotate(new_seed); // old seed moves to "previous"; new_seed is "current"
// Cookies from the previous epoch still verify; older cookies are rejected.
Cookies are 16-byte BLAKE2s keyed MACs. Verification uses subtle::ConstantTimeEq to prevent timing side-channels. Rotating limits the replay window — cookies older than one rotation are rejected.
AWG params (Phase 5a)
AwgParams is the shared junk vocabulary consumed by both obfuscation (mimicry) and the vaiexia-server AWG tunnel provisioner. One MimicryConfig derivation drives both:
use vaiexia_wire::awg::AwgParams;
use vaiexia_wire::mimicry::MimicryConfig;
let config = MimicryConfig {
magic_header: [0xDE, 0xAD, 0xBE, 0xEF],
preamble_junk_len: 128,
pad_bucket: 64,
jitter_ms: (5, 50),
};
let params = AwgParams::from_mimicry(&config);
params.validate().unwrap(); // jmin <= jmax guaranteed by from_mimicry
// Generate the AmneziaWG .conf fields for vaiexia-server.
let fields = params.to_amnezia_fields();
// keys: Jc, Jmin, Jmax, S1, S2, H1, H2, H3, H4 (BTreeMap, stable sorted order)
h1..h4 are derived distinctly from magic_header so different deployments produce different WireGuard message-type transforms. from_mimicry always satisfies jmin ≤ jmax (jmax = jmin + pad_bucket), so validate() never errors on derived params.
API sketch
use vaiexia_wire::mimicry::{AmneziaJunk, MimicryConfig, MimicryProfile};
use rand::thread_rng;
let config = MimicryConfig {
magic_header: [0xCA, 0xFE, 0xBA, 0xBE],
preamble_junk_len: 32,
pad_bucket: 64,
jitter_ms: (5, 25),
};
let profile = AmneziaJunk::new(config);
let mut rng = thread_rng();
// Sender: emit preamble, then shape each encrypted record.
let mut stream = Vec::new();
profile.preamble(&mut stream, &mut rng);
profile.frame_out(b"encrypted record", &mut stream, &mut rng).unwrap();
// Receiver: skip preamble, then extract frames into an accumulating buffer.
let mut buf = stream[profile.preamble_skip()..].to_vec();
use vaiexia_wire::mimicry::FrameInResult;
match profile.frame_in(&mut buf) {
FrameInResult::Record(r) => { /* process r */ }
FrameInResult::NeedMore => { /* wait for more bytes */ }
FrameInResult::Invalid(e) => { /* drop connection */ }
}
Security invariants
| Invariant | Mechanism |
|---|---|
| Nonce uniqueness | Strictly-monotonic u64 counter; returns CounterExhausted at u64::MAX |
| Authenticate-before-replay | AEAD decrypt first; replay window updated only on success |
Panic-free open / decrypt / frame_in |
All slice accesses bounds-checked; junk input returns Err / Invalid |
| Key zeroization | ZeroizeOnDrop on RecordKey; StaticKeypair::private zeroized on drop |
| Server-key pinning | Initiator hard-codes responder static; wrong key → handshake Err |
| Client identity hiding | Initiator static sent encrypted in msg 3 after ephemeral DH |
| Tamper → Err | AEAD tag mismatch on Session::decrypt returns WireError::Noise |
License
Licensed under either of
- Apache License, Version 2.0 (LICENSE-APACHE)
- MIT license (LICENSE-MIT)
at your option.
Copyright (c) 2026 VAIEXIA Team