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,269 @@
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user