No description
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
VAIEXIA Team e2a29a0886
All checks were successful
CI / Release build (push) Successful in 42s
CI / Test (push) Successful in 59s
chore: sync Cargo.lock with blake2 + subtle from the cookie module
2026-07-19 08:43:39 +03:00
.forgejo/workflows docs+ci: readme, licenses, workflows 2026-07-18 23:39:33 +03:00
.github/workflows docs+ci: readme, licenses, workflows 2026-07-18 23:39:33 +03:00
src style: derive Default for MimicryConfig 2026-07-19 08:43:26 +03:00
tests test+docs: datagram primitives property tests + readme 2026-07-19 07:33:36 +03:00
.gitattributes chore: scaffold vaiexia-wire crate 2026-07-18 23:24:02 +03:00
.gitignore docs+ci: readme, licenses, workflows 2026-07-18 23:39:33 +03:00
Cargo.lock chore: sync Cargo.lock with blake2 + subtle from the cookie module 2026-07-19 08:43:39 +03:00
Cargo.toml feat: stateless cookie DoS primitive (BLAKE2s-keyed, src-bound, rotating) 2026-07-19 07:27:47 +03:00
LICENSE-APACHE docs+ci: readme, licenses, workflows 2026-07-18 23:39:33 +03:00
LICENSE-MIT docs+ci: readme, licenses, workflows 2026-07-18 23:39:33 +03:00
README.md test+docs: datagram primitives property tests + readme 2026-07-19 07:33:36 +03:00

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 handshakeNoise_XK_25519_ChaChaPoly_BLAKE2s 3-message mutual-authentication exchange; fully in-memory, no sockets, no async.
  • Post-handshake Session — wraps snow::TransportState for TCP/reliable-path transport AEAD after the handshake completes.
  • Typed errorsWireError extended with Noise(String) and Mimicry(String) variants.
  • Stream mimicry profilesMimicryProfile trait + Vanilla and AmneziaJunk profiles (byte-stream framing).
  • Datagram mimicryDatagramMimicry trait + Passthrough and QuicMimic profiles (whole-datagram shaping for UDP).
  • Cookie DoS primitiveCookieSecret: WG-style stateless handshake-flood defence (BLAKE2s-keyed, src-bound, rotating, constant-time verify).
  • AWG paramsAwgParams: shared junk vocabulary for obfs and vaiexia-server AWG tunnel provisioning, derived from MimicryConfig.

Not yet in scope

  • Socket/transport layer (vaiexia-obfs, Phase 5b)
  • vaiexia-core frame codec integration (Phase 2b)
  • Rekey policy (seam is present; policy TBD)
  • Real-QUIC/quinn stack (TlsMimic over 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 at u64::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.
  • Session is the TCP/reliable path. RecordLayer remains 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/Jmax style) 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.

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

at your option.

Copyright (c) 2026 VAIEXIA Team