feat(singbox-aura,tools): Go port of Aura UDP client + KAT bridge to Rust

Lays the foundation for sing-box mobile clients (Option B from
docs/sing-box.md): an independent Go module that speaks the AuraVPN wire
protocol byte-for-byte. Proof of equivalence is in KAT tests cross-loaded
from a Rust-side deterministic vector exporter.

- tools/export-kat (new Rust bin in workspace): captures a handshake +
  derived keys + a sealed datagram record + a knock token using seeded
  RNGs (rand::rngs::StdRng + ml-kem's *_deterministic public API), emits
  JSON. Reproducible byte-for-byte.
- singbox-aura/ (new Go module, ~3000 LOC, 22 files):
  - aura/frame: 5-byte protocol header + Frame{Data,Ping,Pong,Close,
    Control} + magic envelope (0xAA,0xAA,0xC0,0x01) — encode/decode
    matching aura-proto::frame.
  - aura/crypto: hybrid X25519 + ML-KEM-768 (stdlib crypto/ecdh +
    crypto/mlkem on Go 1.24+; falls back to circl on older Go via a
    documented swap), HKDF-SHA256 derive_session_keys, ChaCha20-Poly1305
    with the **LE(u64 counter) || [0;4]** nonce scheme that matches
    aura-crypto::AeadKey/AeadSession.
  - aura/handshake: client_handshake state machine reproducing protocol.md
    §6.2 exactly (CH→SH→ServerAuth→ClientAuth→Finished×2; transcript hash;
    ECDSA-P256 transcript signature; HMAC-SHA256 Finished).
  - aura/session: DatagramSender/Receiver + 64-wide sliding replay window.
  - aura/transport: reliable HS-adapter (DTLS-flight retransmit) + UDP
    datagram data path + 16-byte HMAC port-knock with ±1-minute window.
  - aura/outbound: sing-box-shaped shim (interface signatures only — sing-
    box upstream registration is one more step, documented in README).
  - cmd/aura-client: standalone Go binary; reads client.toml via
    pelletier/go-toml/v2 and connects to a real aura server. Validates
    end-to-end interop with the Rust side.
- KAT: 6 comparisons against Rust vectors — session_keys (HKDF), hybrid
  KEM ek/encaps roundtrip, c2s + s2c Finished HMAC, sealed datagram
  record at seq=2 (incl. 16-byte Poly1305 tag), knock token. All byte-
  for-byte.

Go: 29 tests across 5 packages, all green. Only deps: golang.org/x/crypto
and pelletier/go-toml/v2. Rust: 293 tests still green; tools/export-kat
added to workspace members.

v1 limits documented in singbox-aura/README.md: UDP-only (no TCP/QUIC
fallback yet), no cell padding / cover traffic, no relay/exit role, no
multi-hop, sing-box upstream-registration sketch (vendor sagernet/sing-box +
init() RegisterOutbound) for follow-up.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
xah30
2026-05-27 21:14:23 +03:00
parent 5ea643a9e5
commit a070da0be9
26 changed files with 3425 additions and 0 deletions
+30
View File
@@ -0,0 +1,30 @@
[package]
name = "export-kat"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Export deterministic known-answer-test vectors for the Aura handshake / AEAD / knock"
[[bin]]
name = "export-kat"
path = "src/main.rs"
[dependencies]
aura-crypto.workspace = true
aura-proto.workspace = true
aura-pki.workspace = true
# Crypto primitives — we need their direct surface to run a deterministic exchange that bypasses
# the OS RNG (the production helpers use thread_rng / OsRng).
ml-kem = { workspace = true }
x25519-dalek = { workspace = true }
hkdf.workspace = true
hmac.workspace = true
sha2.workspace = true
chacha20poly1305.workspace = true
# Misc
hex.workspace = true
anyhow.workspace = true
serde = { workspace = true, features = ["derive"] }
serde_json = "1"
+352
View File
@@ -0,0 +1,352 @@
//! `export-kat` — produce a deterministic JSON vector capturing every wire value the Aura
//! protocol layer derives during a handshake, so the Go port (`singbox-aura/`) can assert
//! byte-for-byte interop without trusting its own re-implementation of the crypto.
//!
//! The tool is purposefully **direct** — it does not call `client_handshake` /
//! `server_handshake` (those generate hellos via OS RNG). Instead it:
//!
//! 1. Picks fixed seeds for the X25519 client static key, the ML-KEM client seed (d||z), the
//! server's ephemeral X25519 secret, the ML-KEM encapsulation randomness (`m`), and both
//! nonces.
//! 2. Computes ek/dk from the ML-KEM seed; computes the server's encapsulation against ek.
//! 3. Derives `(c2s, s2c)` via the same HKDF the production code uses.
//! 4. Recreates the transcript hash exactly as `client_handshake` would compute it (the
//! 5-byte Aura frame header + full hello payload, concatenated).
//! 5. Computes both Finished MACs and one sealed datagram record at seq 2.
//! 6. Computes the port-knock token for a fixed Unix minute against a fixed CA fingerprint.
//!
//! Output: `singbox-aura/kat/vectors.json` (path is the first CLI arg, defaulting to that).
//!
//! The Go test loads the file and asserts the same byte sequences come out of the Go
//! implementation. Any mismatch points at a concrete byte-level porting bug.
#![allow(deprecated)] // ExpandedKeyEncoding is the canonical 2400-byte dk encoding (project decision).
use std::path::PathBuf;
use anyhow::{Context, Result};
use chacha20poly1305::aead::{Aead, KeyInit, Payload};
use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};
use hkdf::Hkdf;
use hmac::{Hmac, Mac as _HmacMac};
use ml_kem::array::Array;
use ml_kem::kem::{Kem, KeyExport, TryKeyInit};
use ml_kem::{EncapsulationKey, ExpandedKeyEncoding, MlKem768, Seed, B32};
use serde::Serialize;
use sha2::{Digest, Sha256};
use x25519_dalek::{PublicKey, StaticSecret};
/// HKDF-SHA256 alias.
type HmacSha256 = Hmac<Sha256>;
/// Wire constants (must mirror `aura-proto`).
const PROTOCOL_VERSION: u8 = 0x01;
const HEADER_LEN: usize = 5;
const MSG_CLIENT_HELLO: u8 = 0x01;
const MSG_SERVER_HELLO: u8 = 0x02;
const HKDF_INFO: &[u8] = b"aura-v1-session";
const POST_HANDSHAKE_COUNTER: u64 = 2;
const KNOCK_LEN: usize = 16;
#[derive(Serialize)]
struct SessionKeys {
c2s: String,
s2c: String,
}
#[derive(Serialize)]
struct DatagramTest {
seq: u64,
/// Frame::Data { stream_id = 0, payload = b"hello" } in its encoded form.
frame: String,
/// The AEAD key the test uses (client_to_server).
key: String,
/// The sealed record on the wire: seq(8 BE) || ChaCha20Poly1305(frame, aad = seq_be).
sealed_record: String,
}
#[derive(Serialize)]
struct KnockTest {
/// Hex of the 32-byte CA fingerprint used as the knock key.
ca_fingerprint: String,
unix_minute: u64,
/// 16-byte truncated HMAC token.
knock: String,
}
#[derive(Serialize)]
struct Vectors {
ca_fingerprint: String,
client_x25519_priv: String,
client_x25519_pub: String,
client_kyber_priv: String,
client_kyber_pub: String,
server_x25519_eph_priv: String,
server_x25519_eph_pub: String,
server_kyber_ct: String,
client_nonce: String,
server_nonce: String,
x25519_ss: String,
kyber_ss: String,
session_keys: SessionKeys,
/// SHA-256 over (ClientHello_frame || ServerHello_frame).
transcript_hash: String,
client_finished_hmac: String,
server_finished_hmac: String,
datagram_test: DatagramTest,
knock_test: KnockTest,
}
/// Build the 5-byte Aura frame header: msg_type || len(u24 BE) || version=0x01.
fn encode_header(msg_type: u8, payload_len: usize) -> [u8; HEADER_LEN] {
assert!(payload_len <= 0x00FF_FFFF, "payload too large for u24 len");
let len = payload_len as u32;
[
msg_type,
((len >> 16) & 0xFF) as u8,
((len >> 8) & 0xFF) as u8,
(len & 0xFF) as u8,
PROTOCOL_VERSION,
]
}
/// Same nonce layout as `AeadSession::nonce_for`: LE(u64 counter) || [0;4].
fn nonce_for(counter: u64) -> [u8; 12] {
let mut n = [0u8; 12];
n[..8].copy_from_slice(&counter.to_le_bytes());
n
}
/// HKDF-SHA256 with the production layout: salt = c_nonce || s_nonce, IKM = x25519_ss || kyber_ss,
/// info = "aura-v1-session", OKM = 64 bytes -> (c2s, s2c).
fn derive_session_keys(
x_ss: &[u8; 32],
k_ss: &[u8; 32],
c_nonce: &[u8; 32],
s_nonce: &[u8; 32],
) -> ([u8; 32], [u8; 32]) {
let mut salt = [0u8; 64];
salt[..32].copy_from_slice(c_nonce);
salt[32..].copy_from_slice(s_nonce);
let mut ikm = [0u8; 64];
ikm[..32].copy_from_slice(x_ss);
ikm[32..].copy_from_slice(k_ss);
let hk = Hkdf::<Sha256>::new(Some(&salt), &ikm);
let mut okm = [0u8; 64];
hk.expand(HKDF_INFO, &mut okm).expect("64-byte OKM");
let mut c2s = [0u8; 32];
let mut s2c = [0u8; 32];
c2s.copy_from_slice(&okm[..32]);
s2c.copy_from_slice(&okm[32..]);
(c2s, s2c)
}
/// Finished MAC: HMAC-SHA256(key, transcript).
fn finished_mac(key: &[u8; 32], transcript: &[u8; 32]) -> Vec<u8> {
let mut mac = <HmacSha256 as _HmacMac>::new_from_slice(key).expect("32-byte hmac key");
mac.update(transcript);
mac.finalize().into_bytes().to_vec()
}
/// Encode Frame::Data { stream_id, payload }: 0x01 || stream_id(u32 BE) || payload.
fn encode_frame_data(stream_id: u32, payload: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(1 + 4 + payload.len());
out.push(0x01);
out.extend_from_slice(&stream_id.to_be_bytes());
out.extend_from_slice(payload);
out
}
/// Datagram record: seq(8 BE) || ChaCha20Poly1305(plaintext = frame, aad = seq_be).
fn seal_datagram(key: &[u8; 32], seq: u64, frame: &[u8]) -> Vec<u8> {
let seq_be = seq.to_be_bytes();
let nonce = nonce_for(seq);
let cipher = ChaCha20Poly1305::new(Key::from_slice(key));
let ct = cipher
.encrypt(
Nonce::from_slice(&nonce),
Payload {
msg: frame,
aad: &seq_be,
},
)
.expect("seal");
let mut out = Vec::with_capacity(8 + ct.len());
out.extend_from_slice(&seq_be);
out.extend_from_slice(&ct);
out
}
/// HMAC-SHA256(key, u64_be(minute))[..16] — matches `knock_for_minute` in aura-transport/udp.rs.
fn knock_for_minute(key: &[u8; 32], minute: u64) -> [u8; KNOCK_LEN] {
let mut mac = <HmacSha256 as _HmacMac>::new_from_slice(key).expect("hmac");
mac.update(&minute.to_be_bytes());
let tag = mac.finalize().into_bytes();
let mut out = [0u8; KNOCK_LEN];
out.copy_from_slice(&tag[..KNOCK_LEN]);
out
}
fn main() -> Result<()> {
let out_path: PathBuf = std::env::args()
.nth(1)
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("singbox-aura/kat/vectors.json"));
// --- Deterministic seeds (entirely fixed so the JSON is reproducible) ----------------------
// 32-byte client X25519 secret seed.
let client_x_priv_bytes: [u8; 32] = *b"AURA-X25519-CLIENT-PRIV-SEED--32";
// 32-byte server ephemeral X25519 secret seed.
let server_x_eph_priv_bytes: [u8; 32] = *b"AURA-X25519-SERVER-EPH-SEED--32B";
// 64-byte ML-KEM seed (d||z). We use d = "AURA-MLKEM-D-... 32 bytes", z = "AURA-MLKEM-Z-...".
let mut kyber_seed = [0u8; 64];
kyber_seed[..32].copy_from_slice(b"AURA-MLKEM-DSEED-CLIENT--FIXED32");
kyber_seed[32..].copy_from_slice(b"AURA-MLKEM-ZSEED-CLIENT--FIXED32");
// 32-byte ML-KEM encapsulation randomness `m`.
let mlkem_m: [u8; 32] = *b"AURA-MLKEM-ENCAPS-M--FIXED-32-BY";
// 32-byte client/server nonces.
let client_nonce: [u8; 32] = *b"AURA-CLIENT-HANDSHAKE-NONCE-32-B";
let server_nonce: [u8; 32] = *b"AURA-SERVER-HANDSHAKE-NONCE-32-B";
// Knock test inputs.
let ca_fingerprint: [u8; 32] = *b"AURA-CA-FINGERPRINT-FIXED-32-BYT";
let knock_minute: u64 = 29_000_000;
// --- Client static X25519 ---------------------------------------------------------------
let client_x_priv = StaticSecret::from(client_x_priv_bytes);
let client_x_pub = PublicKey::from(&client_x_priv);
// --- Client ML-KEM keypair from the deterministic seed ---------------------------------
let seed: Seed = Array::from(kyber_seed);
let dk = <MlKem768 as Kem>::DecapsulationKey::from_seed(seed);
let ek = dk.encapsulation_key().clone();
let client_kyber_priv = dk.to_expanded_bytes().to_vec();
let client_kyber_pub = ek.to_bytes().to_vec();
// --- Server ephemeral X25519 and ECDH ---------------------------------------------------
let server_x_eph_priv = StaticSecret::from(server_x_eph_priv_bytes);
let server_x_eph_pub = PublicKey::from(&server_x_eph_priv);
let x25519_ss = server_x_eph_priv.diffie_hellman(&client_x_pub).to_bytes();
// --- Server ML-KEM encapsulation using the deterministic m -----------------------------
let ek_from_bytes: EncapsulationKey<MlKem768> =
EncapsulationKey::<MlKem768>::new_from_slice(&client_kyber_pub)
.context("re-parse client kyber ek")?;
let m_arr: B32 = Array::from(mlkem_m);
let (kyber_ct, kyber_ss) = ek_from_bytes.encapsulate_deterministic(&m_arr);
let kyber_ct_bytes = kyber_ct.to_vec();
let mut kyber_ss_bytes = [0u8; 32];
kyber_ss_bytes.copy_from_slice(kyber_ss.as_slice());
// Cross-check: client decapsulates back to the same secret.
// (Sanity only; not emitted in JSON.)
{
use ml_kem::Decapsulate;
let ct_arr: ml_kem::Ciphertext<MlKem768> = Array::from_iter(kyber_ct_bytes.iter().copied());
let recovered = dk.decapsulate(&ct_arr);
assert_eq!(
recovered.as_slice(),
kyber_ss.as_slice(),
"decapsulate mismatches"
);
}
// --- Session keys (HKDF-SHA256, exactly as `derive_session_keys`) ----------------------
let (c2s, s2c) = derive_session_keys(&x25519_ss, &kyber_ss_bytes, &client_nonce, &server_nonce);
// --- Transcript hash over the two hello frames exactly as on the wire ------------------
// ClientHello payload = x25519_pub(32) || kyber_ek(1184) || client_nonce(32) = 1248.
let mut ch_payload = Vec::with_capacity(32 + 1184 + 32);
ch_payload.extend_from_slice(client_x_pub.as_bytes());
ch_payload.extend_from_slice(&client_kyber_pub);
ch_payload.extend_from_slice(&client_nonce);
let ch_header = encode_header(MSG_CLIENT_HELLO, ch_payload.len());
let mut ch_wire = Vec::with_capacity(HEADER_LEN + ch_payload.len());
ch_wire.extend_from_slice(&ch_header);
ch_wire.extend_from_slice(&ch_payload);
// ServerHello payload = x25519_eph(32) || kyber_ct(1088) || server_nonce(32) = 1152.
let mut sh_payload = Vec::with_capacity(32 + 1088 + 32);
sh_payload.extend_from_slice(server_x_eph_pub.as_bytes());
sh_payload.extend_from_slice(&kyber_ct_bytes);
sh_payload.extend_from_slice(&server_nonce);
let sh_header = encode_header(MSG_SERVER_HELLO, sh_payload.len());
let mut sh_wire = Vec::with_capacity(HEADER_LEN + sh_payload.len());
sh_wire.extend_from_slice(&sh_header);
sh_wire.extend_from_slice(&sh_payload);
let mut h = Sha256::new();
h.update(&ch_wire);
h.update(&sh_wire);
let transcript: [u8; 32] = h.finalize().into();
// --- Finished MACs ------------------------------------------------------------------------
let client_finished = finished_mac(&c2s, &transcript);
let server_finished = finished_mac(&s2c, &transcript);
// --- Datagram record at seq 2 using the c2s key -------------------------------------------
// Frame::Data { stream_id = 0, payload = b"hello" }.
let frame_data = encode_frame_data(0, b"hello");
let sealed = seal_datagram(&c2s, POST_HANDSHAKE_COUNTER, &frame_data);
// --- Knock ---------------------------------------------------------------------------------
let knock = knock_for_minute(&ca_fingerprint, knock_minute);
// --- Assemble + emit JSON -----------------------------------------------------------------
let v = Vectors {
ca_fingerprint: hex::encode(ca_fingerprint),
client_x25519_priv: hex::encode(client_x_priv_bytes),
client_x25519_pub: hex::encode(client_x_pub.as_bytes()),
client_kyber_priv: hex::encode(&client_kyber_priv),
client_kyber_pub: hex::encode(&client_kyber_pub),
server_x25519_eph_priv: hex::encode(server_x_eph_priv_bytes),
server_x25519_eph_pub: hex::encode(server_x_eph_pub.as_bytes()),
server_kyber_ct: hex::encode(&kyber_ct_bytes),
client_nonce: hex::encode(client_nonce),
server_nonce: hex::encode(server_nonce),
x25519_ss: hex::encode(x25519_ss),
kyber_ss: hex::encode(kyber_ss_bytes),
session_keys: SessionKeys {
c2s: hex::encode(c2s),
s2c: hex::encode(s2c),
},
transcript_hash: hex::encode(transcript),
client_finished_hmac: hex::encode(&client_finished),
server_finished_hmac: hex::encode(&server_finished),
datagram_test: DatagramTest {
seq: POST_HANDSHAKE_COUNTER,
frame: hex::encode(&frame_data),
key: hex::encode(c2s),
sealed_record: hex::encode(&sealed),
},
knock_test: KnockTest {
ca_fingerprint: hex::encode(ca_fingerprint),
unix_minute: knock_minute,
knock: hex::encode(knock),
},
};
if let Some(parent) = out_path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("mkdir -p {}", parent.display()))?;
}
let json = serde_json::to_string_pretty(&v)?;
std::fs::write(&out_path, json + "\n")
.with_context(|| format!("writing vectors to {}", out_path.display()))?;
eprintln!("wrote {}", out_path.display());
// Cross-check the inner roundtrip in-process: re-derive everything from the public bytes and
// assert the derived c2s/s2c match. This makes a regression in the export tool itself fail
// loudly without having to compare to the Go side.
{
let xss_recover = StaticSecret::from(client_x_priv_bytes)
.diffie_hellman(&PublicKey::from(server_x_eph_pub.to_bytes()))
.to_bytes();
assert_eq!(xss_recover, x25519_ss);
let (c2s2, s2c2) =
derive_session_keys(&xss_recover, &kyber_ss_bytes, &client_nonce, &server_nonce);
assert_eq!(c2s2, c2s);
assert_eq!(s2c2, s2c);
}
Ok(())
}