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:
@@ -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"
|
||||
@@ -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(())
|
||||
}
|
||||
Reference in New Issue
Block a user