Files
AuraVPN/singbox-aura/aura/crypto/crypto_test.go
T
xah30 a070da0be9 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>
2026-05-27 21:14:23 +03:00

280 lines
8.6 KiB
Go

package crypto
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"encoding/json"
"os"
"path/filepath"
"runtime"
"testing"
)
// vectorsJSON mirrors the JSON written by tools/export-kat (in Rust). Every field is hex.
type vectorsJSON struct {
CAFingerprint string `json:"ca_fingerprint"`
ClientX25519Priv string `json:"client_x25519_priv"`
ClientX25519Pub string `json:"client_x25519_pub"`
ClientKyberPriv string `json:"client_kyber_priv"`
ClientKyberPub string `json:"client_kyber_pub"`
ServerX25519EphPriv string `json:"server_x25519_eph_priv"`
ServerX25519EphPub string `json:"server_x25519_eph_pub"`
ServerKyberCt string `json:"server_kyber_ct"`
ClientNonce string `json:"client_nonce"`
ServerNonce string `json:"server_nonce"`
X25519SS string `json:"x25519_ss"`
KyberSS string `json:"kyber_ss"`
SessionKeys struct {
C2S string `json:"c2s"`
S2C string `json:"s2c"`
} `json:"session_keys"`
TranscriptHash string `json:"transcript_hash"`
ClientFinishedHmac string `json:"client_finished_hmac"`
ServerFinishedHmac string `json:"server_finished_hmac"`
DatagramTest struct {
Seq uint64 `json:"seq"`
Frame string `json:"frame"`
Key string `json:"key"`
SealedRecord string `json:"sealed_record"`
} `json:"datagram_test"`
KnockTest struct {
CAFingerprint string `json:"ca_fingerprint"`
UnixMinute uint64 `json:"unix_minute"`
Knock string `json:"knock"`
} `json:"knock_test"`
}
// loadVectors finds the vectors file at <module>/kat/vectors.json. The file is created by
//
// cargo run -p export-kat
//
// from the workspace root.
func loadVectors(t *testing.T) *vectorsJSON {
t.Helper()
// crypto_test.go is at singbox-aura/aura/crypto/. The KAT lives at singbox-aura/kat/.
_, thisFile, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("runtime.Caller failed")
}
path := filepath.Join(filepath.Dir(thisFile), "..", "..", "kat", "vectors.json")
data, err := os.ReadFile(path)
if err != nil {
t.Skipf("KAT vectors.json not present at %s — run `cargo run -p export-kat` first: %v", path, err)
return nil
}
var v vectorsJSON
if err := json.Unmarshal(data, &v); err != nil {
t.Fatalf("parse vectors.json: %v", err)
}
return &v
}
func mustHex(t *testing.T, s string) []byte {
t.Helper()
b, err := hex.DecodeString(s)
if err != nil {
t.Fatalf("hex decode %q: %v", s, err)
}
return b
}
func mustHex32(t *testing.T, s string) [32]byte {
b := mustHex(t, s)
if len(b) != 32 {
t.Fatalf("want 32 bytes, got %d", len(b))
}
var out [32]byte
copy(out[:], b)
return out
}
// TestKAT_SessionKeys: HKDF-derive from the shared secrets in the vector reproduces the
// session_keys.{c2s,s2c} byte-for-byte.
func TestKAT_SessionKeys(t *testing.T) {
v := loadVectors(t)
if v == nil {
return
}
xss := mustHex32(t, v.X25519SS)
kss := mustHex32(t, v.KyberSS)
cn := mustHex32(t, v.ClientNonce)
sn := mustHex32(t, v.ServerNonce)
wantC2S := mustHex(t, v.SessionKeys.C2S)
wantS2C := mustHex(t, v.SessionKeys.S2C)
shared := &HybridSharedSecret{X25519SS: xss, MLKEMSS: kss}
keys := DeriveSessionKeys(shared, cn, sn)
if !bytes.Equal(keys.ClientToServer[:], wantC2S) {
t.Fatalf("c2s mismatch:\n got %x\nwant %x", keys.ClientToServer, wantC2S)
}
if !bytes.Equal(keys.ServerToClient[:], wantS2C) {
t.Fatalf("s2c mismatch:\n got %x\nwant %x", keys.ServerToClient, wantS2C)
}
}
// TestKAT_HybridDecapsulateRoundtrip: load the client's deterministic hybrid key from the
// vector, then run Decapsulate against the server's ciphertext. The derived shared secrets must
// match x25519_ss / kyber_ss in the vector.
func TestKAT_HybridDecapsulateRoundtrip(t *testing.T) {
v := loadVectors(t)
if v == nil {
return
}
xPriv := mustHex32(t, v.ClientX25519Priv)
// We don't ship the ml-kem seed in the JSON directly (the export tool uses a fixed seed and
// stores only the expanded private key for diagnostics). Instead, reconstruct from the seed
// the export tool documents — match the literal bytes in tools/export-kat/src/main.rs.
var seed [64]byte
copy(seed[:32], []byte("AURA-MLKEM-DSEED-CLIENT--FIXED32"))
copy(seed[32:], []byte("AURA-MLKEM-ZSEED-CLIENT--FIXED32"))
priv, pub, err := NewHybridPrivateFromBytes(xPriv, seed)
if err != nil {
t.Fatalf("rebuild hybrid: %v", err)
}
// Sanity: the recomputed encapsulation key must match what the Rust side emitted.
if !bytes.Equal(pub.MLKEM, mustHex(t, v.ClientKyberPub)) {
t.Fatalf("ml-kem ek mismatch: Go and Rust derive different bytes from the same seed")
}
if !bytes.Equal(pub.X25519[:], mustHex(t, v.ClientX25519Pub)) {
t.Fatalf("x25519 pub mismatch")
}
// Decapsulate.
ct := &HybridCiphertext{MLKEMCT: mustHex(t, v.ServerKyberCt)}
copy(ct.X25519Eph[:], mustHex(t, v.ServerX25519EphPub))
ss, err := priv.Decapsulate(ct)
if err != nil {
t.Fatalf("decapsulate: %v", err)
}
if !bytes.Equal(ss.X25519SS[:], mustHex(t, v.X25519SS)) {
t.Fatalf("x25519_ss mismatch:\n got %x\nwant %s", ss.X25519SS, v.X25519SS)
}
if !bytes.Equal(ss.MLKEMSS[:], mustHex(t, v.KyberSS)) {
t.Fatalf("kyber_ss mismatch:\n got %x\nwant %s", ss.MLKEMSS, v.KyberSS)
}
}
// TestKAT_ClientFinishedHMAC: HMAC-SHA256(c2s, transcript_hash) reproduces the Rust value.
func TestKAT_ClientFinishedHMAC(t *testing.T) {
v := loadVectors(t)
if v == nil {
return
}
key := mustHex(t, v.SessionKeys.C2S)
transcript := mustHex(t, v.TranscriptHash)
mac := hmac.New(sha256.New, key)
mac.Write(transcript)
got := mac.Sum(nil)
want := mustHex(t, v.ClientFinishedHmac)
if !bytes.Equal(got, want) {
t.Fatalf("client finished mismatch:\n got %x\nwant %x", got, want)
}
}
// TestKAT_ServerFinishedHMAC: HMAC-SHA256(s2c, transcript_hash) reproduces the Rust value.
func TestKAT_ServerFinishedHMAC(t *testing.T) {
v := loadVectors(t)
if v == nil {
return
}
key := mustHex(t, v.SessionKeys.S2C)
transcript := mustHex(t, v.TranscriptHash)
mac := hmac.New(sha256.New, key)
mac.Write(transcript)
got := mac.Sum(nil)
want := mustHex(t, v.ServerFinishedHmac)
if !bytes.Equal(got, want) {
t.Fatalf("server finished mismatch:\n got %x\nwant %x", got, want)
}
}
// TestKAT_SealedDatagramRecord: ChaCha20-Poly1305.Seal under the c2s key at seq 2 with
// aad=seq_be reproduces the exact sealed_record bytes (seq_be || ciphertext).
func TestKAT_SealedDatagramRecord(t *testing.T) {
v := loadVectors(t)
if v == nil {
return
}
key, err := NewAeadKey(mustHex(t, v.DatagramTest.Key))
if err != nil {
t.Fatal(err)
}
frameBytes := mustHex(t, v.DatagramTest.Frame)
seq := v.DatagramTest.Seq
var seqBE [8]byte
binary.BigEndian.PutUint64(seqBE[:], seq)
ct := key.Seal(seq, frameBytes, seqBE[:])
got := append(append([]byte{}, seqBE[:]...), ct...)
want := mustHex(t, v.DatagramTest.SealedRecord)
if !bytes.Equal(got, want) {
t.Fatalf("sealed datagram mismatch:\n got %x\nwant %x", got, want)
}
// Round-trip: opening at the same seq must return the original frame bytes.
pt, err := key.Open(seq, ct, seqBE[:])
if err != nil {
t.Fatalf("open: %v", err)
}
if !bytes.Equal(pt, frameBytes) {
t.Fatal("open returned different plaintext")
}
}
// TestKAT_KnockToken: HMAC-SHA256(ca_fp, u64_be(minute))[:16] matches the Rust knock value.
func TestKAT_KnockToken(t *testing.T) {
v := loadVectors(t)
if v == nil {
return
}
key := mustHex(t, v.KnockTest.CAFingerprint)
var mb [8]byte
binary.BigEndian.PutUint64(mb[:], v.KnockTest.UnixMinute)
mac := hmac.New(sha256.New, key)
mac.Write(mb[:])
tag := mac.Sum(nil)
if len(tag) < 16 {
t.Fatalf("hmac too short: %d", len(tag))
}
got := tag[:16]
want := mustHex(t, v.KnockTest.Knock)
if !bytes.Equal(got, want) {
t.Fatalf("knock mismatch:\n got %x\nwant %x", got, want)
}
}
// TestNonceLayout: explicit sanity that NonceFor matches the documented LE(u64) || 0x00000000.
func TestNonceLayout(t *testing.T) {
if got := NonceFor(0); got != ([NonceLen]byte{}) {
t.Fatalf("counter 0: want zero, got %x", got)
}
n := NonceFor(0x0807060504030201)
if !bytes.Equal(n[:8], []byte{1, 2, 3, 4, 5, 6, 7, 8}) {
t.Fatalf("LE layout wrong: %x", n[:8])
}
if !bytes.Equal(n[8:], []byte{0, 0, 0, 0}) {
t.Fatalf("upper 4 bytes not zero: %x", n[8:])
}
}
// TestAeadSessionCounterMonotonic: Seal/Open lock-step advances the counter by exactly 1.
func TestAeadSessionCounterMonotonic(t *testing.T) {
key := make([]byte, 32)
for i := range key {
key[i] = byte(i)
}
s, err := NewAeadSession(key)
if err != nil {
t.Fatal(err)
}
if s.Counter() != 0 {
t.Fatalf("initial counter %d", s.Counter())
}
for want := uint64(1); want <= 5; want++ {
_ = s.Seal([]byte("x"), nil)
if s.Counter() != want {
t.Fatalf("after %d seals: counter %d", want, s.Counter())
}
}
}