a070da0be9
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>
192 lines
5.0 KiB
Go
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
|
|
}
|