Files
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

192 lines
5.0 KiB
Go

// aura-client is a standalone Go port of the Aura UDP client. It dials a Rust-side `aura
// server`, completes the mutual-auth post-quantum handshake, and exchanges a single round-trip
// "hello" / echo on the data path to prove the connection is up.
//
// Usage:
//
// aura-client --config client.toml [--message "hello aura"]
//
// The TOML schema is a small subset of the production Rust client.toml; only the fields the Go
// client actually needs are read (server address, PKI paths, expected server name, optional
// knock). See README.md for a full example file.
package main
import (
"context"
"crypto/sha256"
"encoding/pem"
"errors"
"flag"
"fmt"
"log"
"os"
"os/user"
"path/filepath"
"strings"
"time"
"github.com/pelletier/go-toml/v2"
"github.com/aura/singbox-aura/aura/handshake"
"github.com/aura/singbox-aura/aura/transport"
)
// config is the on-disk schema. Mirrors the relevant subset of config/client.toml.example.
type config struct {
Client struct {
ServerAddr string `toml:"server_addr"`
SNI string `toml:"sni"`
} `toml:"client"`
PKI struct {
CACert string `toml:"ca_cert"`
Cert string `toml:"cert"`
Key string `toml:"key"`
} `toml:"pki"`
Transport struct {
Knock struct {
Enabled bool `toml:"enabled"`
KnockSecretSource string `toml:"knock_secret_source"`
} `toml:"knock"`
} `toml:"transport"`
}
func main() {
cfgPath := flag.String("config", "client.toml", "path to TOML config")
message := flag.String("message", "hello aura", "single message to send on the data path")
flag.Parse()
cfg, err := loadConfig(*cfgPath)
if err != nil {
log.Fatalf("load config: %v", err)
}
hsCfg, err := buildHandshakeConfig(cfg)
if err != nil {
log.Fatalf("build handshake config: %v", err)
}
opts := transport.DefaultOptions()
if cfg.Transport.Knock.Enabled {
// The Rust side derives the knock key as SHA-256(CA-cert-DER). Mirror that here.
key, err := caKnockKey(cfg.PKI.CACert)
if err != nil {
log.Fatalf("knock key: %v", err)
}
opts.KnockEnabled = true
opts.KnockKey = key
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
log.Printf("aura-client: dialing %s (sni=%s, knock=%t)", cfg.Client.ServerAddr, cfg.Client.SNI, opts.KnockEnabled)
conn, err := transport.Dial(ctx, cfg.Client.ServerAddr, hsCfg, opts)
if err != nil {
log.Fatalf("dial: %v", err)
}
defer conn.Close()
log.Printf("aura-client: connected (peer=%s)", conn.PeerID())
if err := conn.Send([]byte(*message)); err != nil {
log.Fatalf("send: %v", err)
}
log.Printf("aura-client: sent %d bytes", len(*message))
conn.Close()
}
// loadConfig reads + parses the TOML, expanding ~ in PKI paths.
func loadConfig(path string) (*config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read %s: %w", path, err)
}
var c config
if err := toml.Unmarshal(data, &c); err != nil {
return nil, fmt.Errorf("parse toml: %w", err)
}
c.PKI.CACert = expandHome(c.PKI.CACert)
c.PKI.Cert = expandHome(c.PKI.Cert)
c.PKI.Key = expandHome(c.PKI.Key)
if c.Client.ServerAddr == "" {
return nil, fmt.Errorf("config: [client].server_addr is required")
}
if c.Client.SNI == "" {
return nil, fmt.Errorf("config: [client].sni is required")
}
for _, p := range []string{c.PKI.CACert, c.PKI.Cert, c.PKI.Key} {
if p == "" {
return nil, fmt.Errorf("config: [pki].{ca_cert,cert,key} are all required")
}
}
return &c, nil
}
func buildHandshakeConfig(c *config) (*handshake.ClientConfig, error) {
ca, err := os.ReadFile(c.PKI.CACert)
if err != nil {
return nil, fmt.Errorf("read CA: %w", err)
}
cert, err := os.ReadFile(c.PKI.Cert)
if err != nil {
return nil, fmt.Errorf("read cert: %w", err)
}
key, err := os.ReadFile(c.PKI.Key)
if err != nil {
return nil, fmt.Errorf("read key: %w", err)
}
return &handshake.ClientConfig{
CAPEM: ca,
CertPEM: cert,
KeyPEM: key,
ServerName: c.Client.SNI,
}, nil
}
// caKnockKey loads the CA PEM and returns sha256(CA-cert-DER) — same derivation the Rust
// side uses for the knock shared secret.
func caKnockKey(path string) ([32]byte, error) {
var zero [32]byte
pemBytes, err := os.ReadFile(path)
if err != nil {
return zero, fmt.Errorf("read CA: %w", err)
}
der, err := firstCertDER(pemBytes)
if err != nil {
return zero, err
}
return sha256.Sum256(der), nil
}
// firstCertDER decodes the first CERTIFICATE PEM block.
func firstCertDER(pemBytes []byte) ([]byte, error) {
rest := pemBytes
for {
block, r := pem.Decode(rest)
if block == nil {
return nil, errors.New("no CERTIFICATE block in PEM")
}
if block.Type == "CERTIFICATE" {
return block.Bytes, nil
}
rest = r
}
}
// expandHome turns a leading ~/ or just ~ into the user's home directory. Matches what the
// Rust client.toml loader does.
func expandHome(p string) string {
if p == "" || !strings.HasPrefix(p, "~") {
return p
}
u, err := user.Current()
if err != nil || u.HomeDir == "" {
return p
}
if p == "~" {
return u.HomeDir
}
if strings.HasPrefix(p, "~/") {
return filepath.Join(u.HomeDir, p[2:])
}
return p
}