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

270 lines
8.4 KiB
Go

// Package frame implements Aura's wire framing: the 5-byte protocol header and the
// application-level Frame{Data,Ping,Pong,Close}.
//
// This is a byte-for-byte port of crates/aura-proto/src/frame.rs. The Rust unit tests in that
// file are the wire spec; matching them here keeps the Go port interoperable with the Rust
// server.
//
// Wire layout (from docs/protocol.md §6.1):
//
// byte 0 : msg_type (u8)
// bytes 1..4 : length (u24, big-endian) — payload length in bytes
// byte 4 : version = 0x01
// bytes 5.. : payload (length bytes)
package frame
import (
"encoding/binary"
"errors"
"fmt"
"io"
)
// HeaderLen is the size of the protocol header in bytes.
const HeaderLen = 5
// ProtocolVersion is the constant carried in byte 4 of every header.
const ProtocolVersion byte = 0x01
// MaxPayloadLen is the largest payload expressible by the u24 length field.
const MaxPayloadLen = 0x00FF_FFFF
// MsgType is the on-wire message-type discriminant carried in byte 0 of the header.
type MsgType byte
// Message-type bytes (must match the Rust MsgType repr in aura-proto/frame.rs).
const (
MsgClientHello MsgType = 0x01
MsgServerHello MsgType = 0x02
MsgClientAuth MsgType = 0x03
MsgServerAuth MsgType = 0x04
MsgFinished MsgType = 0x05
MsgData MsgType = 0x06
MsgAlert MsgType = 0xFF
)
// String returns the short name of the message type, for logs.
func (m MsgType) String() string {
switch m {
case MsgClientHello:
return "ClientHello"
case MsgServerHello:
return "ServerHello"
case MsgClientAuth:
return "ClientAuth"
case MsgServerAuth:
return "ServerAuth"
case MsgFinished:
return "Finished"
case MsgData:
return "Data"
case MsgAlert:
return "Alert"
default:
return fmt.Sprintf("MsgType(0x%02X)", byte(m))
}
}
// Errors returned by the codec. They mirror the ProtoError variants the Rust side returns so
// callers can map them onto identical wire alerts.
var (
ErrFrameTooLarge = errors.New("aura/frame: payload exceeds 16 MiB u24 length field")
ErrBadVersion = errors.New("aura/frame: header byte 4 is not protocol version 0x01")
ErrUnknownMsgType = errors.New("aura/frame: unknown message-type byte")
ErrMalformedFrame = errors.New("aura/frame: malformed application frame")
)
// EncodeHeader builds a 5-byte header for msgType carrying a payload of payloadLen bytes.
func EncodeHeader(msgType MsgType, payloadLen int) ([HeaderLen]byte, error) {
var h [HeaderLen]byte
if payloadLen < 0 || payloadLen > MaxPayloadLen {
return h, fmt.Errorf("%w: len=%d", ErrFrameTooLarge, payloadLen)
}
h[0] = byte(msgType)
// u24 big-endian.
h[1] = byte((payloadLen >> 16) & 0xFF)
h[2] = byte((payloadLen >> 8) & 0xFF)
h[3] = byte(payloadLen & 0xFF)
h[4] = ProtocolVersion
return h, nil
}
// DecodeHeader parses a 5-byte header into (msgType, payloadLen).
func DecodeHeader(h [HeaderLen]byte) (MsgType, int, error) {
if h[4] != ProtocolVersion {
return 0, 0, fmt.Errorf("%w: got 0x%02X", ErrBadVersion, h[4])
}
mt := MsgType(h[0])
switch mt {
case MsgClientHello, MsgServerHello, MsgClientAuth, MsgServerAuth, MsgFinished, MsgData, MsgAlert:
// recognized
default:
return 0, 0, fmt.Errorf("%w: got 0x%02X", ErrUnknownMsgType, h[0])
}
plen := int(h[1])<<16 | int(h[2])<<8 | int(h[3])
return mt, plen, nil
}
// RawFrame is a frame as it was on the wire: type, header bytes (useful for AEAD AAD and the
// handshake transcript hash), and payload bytes.
type RawFrame struct {
MsgType MsgType
Header [HeaderLen]byte
Payload []byte
}
// WireBytes returns header || payload in a fresh slice — used to feed the transcript hash, which
// hashes the bytes exactly as transmitted.
func (rf *RawFrame) WireBytes() []byte {
out := make([]byte, 0, HeaderLen+len(rf.Payload))
out = append(out, rf.Header[:]...)
out = append(out, rf.Payload...)
return out
}
// WriteFrame serializes header || payload and writes it to w. Single Write, so on a streaming
// transport a single TCP segment is preferred.
func WriteFrame(w io.Writer, msgType MsgType, payload []byte) error {
h, err := EncodeHeader(msgType, len(payload))
if err != nil {
return err
}
buf := make([]byte, 0, HeaderLen+len(payload))
buf = append(buf, h[:]...)
buf = append(buf, payload...)
_, err = w.Write(buf)
return err
}
// ReadFrame reads one full frame (header || payload) from r.
func ReadFrame(r io.Reader) (*RawFrame, error) {
var h [HeaderLen]byte
if _, err := io.ReadFull(r, h[:]); err != nil {
return nil, err
}
mt, plen, err := DecodeHeader(h)
if err != nil {
return nil, err
}
payload := make([]byte, plen)
if plen > 0 {
if _, err := io.ReadFull(r, payload); err != nil {
return nil, err
}
}
return &RawFrame{MsgType: mt, Header: h, Payload: payload}, nil
}
// ----------------------------------------------------------------------------------------------
// Application frames (§6.3) — Data, Ping, Pong, Close, Control.
// ----------------------------------------------------------------------------------------------
// FrameKind identifies the Application-frame variant.
type FrameKind byte
// On-wire frame tags (must match crates/aura-proto/src/frame.rs frame_tag::*).
const (
FrameData FrameKind = 0x01
FramePing FrameKind = 0x02
FramePong FrameKind = 0x03
FrameClose FrameKind = 0x04
)
// Frame is the post-handshake application payload carried inside an AEAD-sealed MsgData record.
// One Frame is mapped to one of the four variants by Kind.
type Frame struct {
Kind FrameKind
StreamID uint32 // Data only
Payload []byte // Data only
Seq uint32 // Ping / Pong only
Code byte // Close only
Reason string // Close only
}
// EncodeFrame serializes f into its compact byte encoding (all multi-byte ints big-endian):
//
// Data : 0x01 || stream_id(u32) || payload
// Ping : 0x02 || seq(u32)
// Pong : 0x03 || seq(u32)
// Close : 0x04 || code(u8) || reason_len(u32) || reason_utf8
func EncodeFrame(f *Frame) []byte {
switch f.Kind {
case FrameData:
out := make([]byte, 1+4+len(f.Payload))
out[0] = byte(FrameData)
binary.BigEndian.PutUint32(out[1:5], f.StreamID)
copy(out[5:], f.Payload)
return out
case FramePing:
out := make([]byte, 1+4)
out[0] = byte(FramePing)
binary.BigEndian.PutUint32(out[1:5], f.Seq)
return out
case FramePong:
out := make([]byte, 1+4)
out[0] = byte(FramePong)
binary.BigEndian.PutUint32(out[1:5], f.Seq)
return out
case FrameClose:
reason := []byte(f.Reason)
out := make([]byte, 1+1+4+len(reason))
out[0] = byte(FrameClose)
out[1] = f.Code
binary.BigEndian.PutUint32(out[2:6], uint32(len(reason)))
copy(out[6:], reason)
return out
default:
// Programmer error — encode nothing rather than panic so call sites can defensively
// inspect the returned length.
return nil
}
}
// DecodeFrame parses one byte-encoded Frame (the inverse of EncodeFrame).
func DecodeFrame(b []byte) (*Frame, error) {
if len(b) == 0 {
return nil, fmt.Errorf("%w: empty frame", ErrMalformedFrame)
}
tag := FrameKind(b[0])
rest := b[1:]
switch tag {
case FrameData:
if len(rest) < 4 {
return nil, fmt.Errorf("%w: Data: missing stream_id", ErrMalformedFrame)
}
sid := binary.BigEndian.Uint32(rest[:4])
// Payload is everything after the 4-byte stream_id.
payload := make([]byte, len(rest)-4)
copy(payload, rest[4:])
return &Frame{Kind: FrameData, StreamID: sid, Payload: payload}, nil
case FramePing:
if len(rest) < 4 {
return nil, fmt.Errorf("%w: Ping: truncated seq", ErrMalformedFrame)
}
return &Frame{Kind: FramePing, Seq: binary.BigEndian.Uint32(rest[:4])}, nil
case FramePong:
if len(rest) < 4 {
return nil, fmt.Errorf("%w: Pong: truncated seq", ErrMalformedFrame)
}
return &Frame{Kind: FramePong, Seq: binary.BigEndian.Uint32(rest[:4])}, nil
case FrameClose:
if len(rest) < 1 {
return nil, fmt.Errorf("%w: Close: missing code", ErrMalformedFrame)
}
code := rest[0]
if len(rest) < 5 {
return nil, fmt.Errorf("%w: Close: missing reason_len", ErrMalformedFrame)
}
rlen := int(binary.BigEndian.Uint32(rest[1:5]))
if len(rest) < 5+rlen {
return nil, fmt.Errorf("%w: Close: truncated reason", ErrMalformedFrame)
}
// We do not enforce strict UTF-8 here (Go strings can hold any bytes); the Rust side
// rejects non-UTF-8 in this slot, so peers that follow the spec only ever send valid
// strings.
return &Frame{Kind: FrameClose, Code: code, Reason: string(rest[5 : 5+rlen])}, nil
default:
return nil, fmt.Errorf("%w: unknown frame tag 0x%02X", ErrMalformedFrame, byte(tag))
}
}