feat(canary): mysql wire protocol (handshake + auth + ERR packets)

Phase 8 first commit. Pure encoding/decoding layer for the MySQL
wire protocol — no I/O orchestration, no DB lookups, no business
logic. Three primary functions plus utility helpers, all driven by
the byte-exact spec §9.7 lines 1363-1403:

  - BuildHandshakeV10(connID, authData[20]) []byte
    Server's initial greeting packet (0x0a protocol version,
    "5.7.40-canary" server version, random auth-plugin-data split
    into 8+12 byte parts, capability flags 0xf7ff / 0x81ff,
    utf8mb4_unicode_ci charset, mysql_native_password plugin name).
    Wraps in 3-byte LE length + 1-byte sequence ID (0x00).

  - ReadClientAuth(r io.Reader) (*ClientAuth, error)
    Parses HandshakeResponse41. Returns the username (null-terminated,
    extracted starting at byte offset 32 after the 4+4+1+23-byte
    fixed header) plus the client's capabilities, max packet size,
    and charset for forensic richness. Defensive bounds checking
    against ErrInvalidPayload / ErrUsernameMissing / ErrShortPacket /
    ErrPacketTooLarge sentinels. 64KB max packet size cap.

  - BuildAccessDeniedErr(username, sourceHost string) []byte
    Standard MySQL ERR_Packet 1045 with SQL state "28000" and the
    exact "Access denied for user '%s'@'%s' (using password: YES)"
    message kubectl and the mysql client both display verbatim.
    Sequence ID 0x02 (after handshake=0 and client auth=1).

Plus crypto/rand-backed helpers NewRandomAuthData (20 bytes for the
challenge) and NewRandomConnectionID (uint32). Consistent with the
crypto/rand discipline from Phase 0 supplement, Phase 5 (pdf), Phase 7
(envfile).

Tests (~24 cases): byte-exact assertions on packet headers, payload
layout, sequence IDs; round-trip Build/Read for 5 username variants;
error paths (short packet, missing username terminator, empty reader,
oversize packet rejected mid-stream); randomness distinctness (50
calls → near-50 unique values).

Handler + server land in next commit; generator + registry in the
one after. Trigger interface conformance for the mysql Generator will
be a sentinel-error no-op (mysql triggers over TCP, not HTTP — Phase
9 router doesn't mount any HTTP path to mysql.Trigger).

go test -race -timeout=60s ./internal/token/generators/mysql/...
passes, golangci-lint clean.
This commit is contained in:
CarterPerez-dev 2026-05-13 14:49:03 -04:00
parent 3d9b97da21
commit 712202abc0
2 changed files with 549 additions and 0 deletions

View File

@ -0,0 +1,220 @@
// ©AngelaMos | 2026
// protocol.go
package mysql
import (
"bytes"
"crypto/rand"
"encoding/binary"
"errors"
"fmt"
"io"
)
const (
packetHeaderSize = 4
protocolVersion = 0x0a
serverVersionString = "5.7.40-canary"
capabilityFlagsLower = uint16(0xf7ff)
capabilityFlagsUpper = uint16(0x81ff)
characterSetUtf8MB4 = 0x21
statusFlags = uint16(0x0002)
authPluginDataLen = byte(0x15)
authPluginName = "mysql_native_password"
errPacketHeader = 0xff
errCodeAccessDenied = uint16(1045)
sqlStateMarker = '#'
sqlStateAccessDenied = "28000"
seqIDServerHandshake byte = 0x00
seqIDServerErr byte = 0x02
maxPacketSize = 0x00ffffff
handshakeResponseMaxBytes = 64 * 1024
handshakeResponseFillerLen = 23
handshakeResponseHeaderBytes = 4 + 4 + 1 + handshakeResponseFillerLen
authPluginDataTotalLen = 20
authPluginDataPart1Len = 8
authPluginDataPart2Len = 12
)
var (
ErrShortPacket = errors.New("mysql: short packet header")
ErrPacketTooLarge = errors.New("mysql: packet exceeds max size")
ErrInvalidPayload = errors.New("mysql: invalid payload structure")
ErrUsernameMissing = errors.New(
"mysql: username missing or unterminated",
)
ErrHTTPTriggerNotSupported = errors.New(
"mysql: Trigger via HTTP is not applicable (use the TCP listener)",
)
)
type ClientAuth struct {
Capabilities uint32
MaxPacketSize uint32
Charset uint8
Username string
}
func BuildHandshakeV10(
connID uint32,
authData [authPluginDataTotalLen]byte,
) ([]byte, error) {
var payload bytes.Buffer
payload.WriteByte(protocolVersion)
payload.WriteString(serverVersionString)
payload.WriteByte(0x00)
var connIDBytes [4]byte
binary.LittleEndian.PutUint32(connIDBytes[:], connID)
payload.Write(connIDBytes[:])
payload.Write(authData[:authPluginDataPart1Len])
payload.WriteByte(0x00)
var capLower [2]byte
binary.LittleEndian.PutUint16(capLower[:], capabilityFlagsLower)
payload.Write(capLower[:])
payload.WriteByte(characterSetUtf8MB4)
var statusBytes [2]byte
binary.LittleEndian.PutUint16(statusBytes[:], statusFlags)
payload.Write(statusBytes[:])
var capUpper [2]byte
binary.LittleEndian.PutUint16(capUpper[:], capabilityFlagsUpper)
payload.Write(capUpper[:])
payload.WriteByte(authPluginDataLen)
var reserved [10]byte
payload.Write(reserved[:])
payload.Write(authData[authPluginDataPart1Len:])
payload.WriteByte(0x00)
payload.WriteString(authPluginName)
payload.WriteByte(0x00)
return wrapPacket(payload.Bytes(), seqIDServerHandshake)
}
func ReadClientAuth(r io.Reader) (*ClientAuth, error) {
payload, err := readPacket(r)
if err != nil {
return nil, err
}
if len(payload) < handshakeResponseHeaderBytes+1 {
return nil, fmt.Errorf(
"%w: have %d bytes, need at least %d",
ErrInvalidPayload,
len(payload),
handshakeResponseHeaderBytes+1,
)
}
auth := &ClientAuth{
Capabilities: binary.LittleEndian.Uint32(payload[0:4]),
MaxPacketSize: binary.LittleEndian.Uint32(payload[4:8]),
Charset: payload[8],
}
usernameStart := handshakeResponseHeaderBytes
rel := bytes.IndexByte(payload[usernameStart:], 0x00)
if rel < 0 {
return nil, ErrUsernameMissing
}
auth.Username = string(payload[usernameStart : usernameStart+rel])
return auth, nil
}
func BuildAccessDeniedErr(username, sourceHost string) ([]byte, error) {
msg := fmt.Sprintf(
`Access denied for user '%s'@'%s' (using password: YES)`,
username,
sourceHost,
)
var payload bytes.Buffer
payload.WriteByte(errPacketHeader)
var code [2]byte
binary.LittleEndian.PutUint16(code[:], errCodeAccessDenied)
payload.Write(code[:])
payload.WriteByte(sqlStateMarker)
payload.WriteString(sqlStateAccessDenied)
payload.WriteString(msg)
return wrapPacket(payload.Bytes(), seqIDServerErr)
}
func wrapPacket(payload []byte, seqID byte) ([]byte, error) {
if len(payload) > maxPacketSize {
return nil, fmt.Errorf(
"%w: payload %d > max %d",
ErrPacketTooLarge,
len(payload),
maxPacketSize,
)
}
out := make([]byte, packetHeaderSize+len(payload))
n := len(payload)
out[0] = byte(n & 0xff)
out[1] = byte((n >> 8) & 0xff)
out[2] = byte((n >> 16) & 0xff)
out[3] = seqID
copy(out[packetHeaderSize:], payload)
return out, nil
}
func readPacket(r io.Reader) ([]byte, error) {
var hdr [packetHeaderSize]byte
if _, err := io.ReadFull(r, hdr[:]); err != nil {
return nil, fmt.Errorf("%w: %w", ErrShortPacket, err)
}
payloadLen := uint32(hdr[0]) |
uint32(hdr[1])<<8 |
uint32(hdr[2])<<16
if payloadLen > handshakeResponseMaxBytes {
return nil, fmt.Errorf(
"%w: payload %d > limit %d",
ErrPacketTooLarge,
payloadLen,
handshakeResponseMaxBytes,
)
}
if payloadLen == 0 {
return nil, nil
}
payload := make([]byte, payloadLen)
if _, err := io.ReadFull(r, payload); err != nil {
return nil, fmt.Errorf("mysql: read payload: %w", err)
}
return payload, nil
}
func NewRandomAuthData() ([authPluginDataTotalLen]byte, error) {
var data [authPluginDataTotalLen]byte
if _, err := rand.Read(data[:]); err != nil {
return data, fmt.Errorf("mysql: random auth data: %w", err)
}
return data, nil
}
func NewRandomConnectionID() (uint32, error) {
var b [4]byte
if _, err := rand.Read(b[:]); err != nil {
return 0, fmt.Errorf("mysql: random connection id: %w", err)
}
return binary.LittleEndian.Uint32(b[:]), nil
}

View File

@ -0,0 +1,329 @@
// ©AngelaMos | 2026
// protocol_test.go
package mysql_test
import (
"bytes"
"encoding/binary"
"io"
"strings"
"testing"
"github.com/stretchr/testify/require"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/mysql"
)
const (
packetHeaderSize = 4
expectedProtoVersion = 0x0a
expectedCharset = 0x21
)
func buildHandshakeResponse41(t *testing.T, username string) []byte {
t.Helper()
var payload bytes.Buffer
var caps [4]byte
binary.LittleEndian.PutUint32(caps[:], 0x0001a285)
payload.Write(caps[:])
var maxPkt [4]byte
binary.LittleEndian.PutUint32(maxPkt[:], 0x01000000)
payload.Write(maxPkt[:])
payload.WriteByte(expectedCharset)
var filler [23]byte
payload.Write(filler[:])
payload.WriteString(username)
payload.WriteByte(0x00)
body := payload.Bytes()
out := make([]byte, packetHeaderSize+len(body))
n := len(body)
out[0] = byte(n & 0xff)
out[1] = byte((n >> 8) & 0xff)
out[2] = byte((n >> 16) & 0xff)
out[3] = 0x01
copy(out[packetHeaderSize:], body)
return out
}
func TestBuildHandshakeV10_HasCorrectPacketHeader(t *testing.T) {
auth := [20]byte{
1, 2, 3, 4, 5, 6, 7, 8, 9, 10,
11, 12, 13, 14, 15, 16, 17, 18, 19, 20,
}
pkt, err := mysql.BuildHandshakeV10(0xdeadbeef, auth)
require.NoError(t, err)
require.Greater(t, len(pkt), packetHeaderSize)
payloadLen := int(pkt[0]) |
int(pkt[1])<<8 |
int(pkt[2])<<16
require.Equal(t, len(pkt)-packetHeaderSize, payloadLen)
require.Equal(t, byte(0x00), pkt[3], "server handshake sequence ID is 0")
}
func TestBuildHandshakeV10_PayloadStartsWithProtocolVersion(t *testing.T) {
var auth [20]byte
pkt, err := mysql.BuildHandshakeV10(0x1234, auth)
require.NoError(t, err)
require.Equal(t, byte(expectedProtoVersion), pkt[packetHeaderSize])
}
func TestBuildHandshakeV10_ContainsServerVersionString(t *testing.T) {
var auth [20]byte
pkt, err := mysql.BuildHandshakeV10(0x1234, auth)
require.NoError(t, err)
require.Contains(
t,
string(pkt),
"5.7.40-canary",
"handshake must advertise the canary-server version string for verisimilitude",
)
}
func TestBuildHandshakeV10_AdvertisesMySQLNativePassword(t *testing.T) {
var auth [20]byte
pkt, err := mysql.BuildHandshakeV10(0x1234, auth)
require.NoError(t, err)
require.Contains(t, string(pkt), "mysql_native_password")
}
func TestBuildHandshakeV10_EmbedsConnectionIDLittleEndian(t *testing.T) {
var auth [20]byte
connID := uint32(0xdeadbeef)
pkt, err := mysql.BuildHandshakeV10(connID, auth)
require.NoError(t, err)
versionEnd := bytes.IndexByte(
pkt[packetHeaderSize+1:],
0x00,
) + packetHeaderSize + 1
require.Greater(t, versionEnd, packetHeaderSize+1)
connIDStart := versionEnd + 1
got := binary.LittleEndian.Uint32(pkt[connIDStart : connIDStart+4])
require.Equal(t, connID, got)
}
func TestBuildHandshakeV10_EmbedsAuthDataInTwoParts(t *testing.T) {
auth := [20]byte{
10, 11, 12, 13, 14, 15, 16, 17, 18, 19,
20, 21, 22, 23, 24, 25, 26, 27, 28, 29,
}
pkt, err := mysql.BuildHandshakeV10(0xcafef00d, auth)
require.NoError(t, err)
require.Contains(
t,
string(pkt),
string(auth[:8]),
"first 8 bytes of auth data must appear in handshake",
)
require.Contains(
t,
string(pkt),
string(auth[8:]),
"last 12 bytes of auth data must appear in handshake (after part-1 + filler + caps + ...)",
)
}
func TestReadClientAuth_ExtractsUsername(t *testing.T) {
pkt := buildHandshakeResponse41(t, "canary_abc123def456")
auth, err := mysql.ReadClientAuth(bytes.NewReader(pkt))
require.NoError(t, err)
require.NotNil(t, auth)
require.Equal(t, "canary_abc123def456", auth.Username)
}
func TestReadClientAuth_CapturesCapabilities(t *testing.T) {
pkt := buildHandshakeResponse41(t, "anyone")
auth, err := mysql.ReadClientAuth(bytes.NewReader(pkt))
require.NoError(t, err)
require.Equal(t, uint32(0x0001a285), auth.Capabilities)
require.Equal(t, uint32(0x01000000), auth.MaxPacketSize)
require.Equal(t, uint8(expectedCharset), auth.Charset)
}
func TestReadClientAuth_EmptyUsernameStillParses(t *testing.T) {
pkt := buildHandshakeResponse41(t, "")
auth, err := mysql.ReadClientAuth(bytes.NewReader(pkt))
require.NoError(t, err)
require.Empty(t, auth.Username)
}
func TestReadClientAuth_ShortPayloadReturnsError(t *testing.T) {
short := make([]byte, packetHeaderSize+5)
short[0] = 0x05
_, err := mysql.ReadClientAuth(bytes.NewReader(short))
require.Error(t, err)
require.ErrorIs(t, err, mysql.ErrInvalidPayload)
}
func TestReadClientAuth_NoUsernameTerminatorReturnsError(t *testing.T) {
var payload bytes.Buffer
var caps [4]byte
payload.Write(caps[:])
var maxPkt [4]byte
payload.Write(maxPkt[:])
payload.WriteByte(expectedCharset)
var filler [23]byte
payload.Write(filler[:])
payload.WriteString("nocan_terminator_here_no_null")
body := payload.Bytes()
out := make([]byte, packetHeaderSize+len(body))
n := len(body)
out[0] = byte(n & 0xff)
out[1] = byte((n >> 8) & 0xff)
out[2] = byte((n >> 16) & 0xff)
out[3] = 0x01
copy(out[packetHeaderSize:], body)
_, err := mysql.ReadClientAuth(bytes.NewReader(out))
require.Error(t, err)
require.ErrorIs(t, err, mysql.ErrUsernameMissing)
}
func TestReadClientAuth_EmptyReaderReturnsShortPacket(t *testing.T) {
_, err := mysql.ReadClientAuth(bytes.NewReader(nil))
require.Error(t, err)
require.ErrorIs(t, err, mysql.ErrShortPacket)
}
func TestReadClientAuth_OversizePacketRejected(t *testing.T) {
var hdr [packetHeaderSize]byte
hdr[0] = 0x01
hdr[1] = 0x00
hdr[2] = 0x10
hdr[3] = 0x01
r := io.MultiReader(
bytes.NewReader(hdr[:]),
strings.NewReader(strings.Repeat("X", 70000)),
)
_, err := mysql.ReadClientAuth(r)
require.Error(t, err)
require.ErrorIs(t, err, mysql.ErrPacketTooLarge)
}
func TestBuildAccessDeniedErr_HasCorrectMarkerAndCode(t *testing.T) {
pkt, err := mysql.BuildAccessDeniedErr("canary_xyz", "203.0.113.50")
require.NoError(t, err)
require.Greater(t, len(pkt), packetHeaderSize)
require.Equal(
t,
byte(0xff),
pkt[packetHeaderSize],
"ERR packet header byte",
)
code := binary.LittleEndian.Uint16(
pkt[packetHeaderSize+1 : packetHeaderSize+3],
)
require.Equal(
t,
uint16(1045),
code,
"MySQL error code 1045 (access denied)",
)
}
func TestBuildAccessDeniedErr_HasSQLStateMarkerAnd28000(t *testing.T) {
pkt, err := mysql.BuildAccessDeniedErr("canary_xyz", "203.0.113.50")
require.NoError(t, err)
require.Equal(
t,
byte('#'),
pkt[packetHeaderSize+3],
"SQL state marker must be '#'",
)
require.Equal(
t,
"28000",
string(pkt[packetHeaderSize+4:packetHeaderSize+9]),
"SQL state 28000 (invalid authorization)",
)
}
func TestBuildAccessDeniedErr_MessageContainsUserAndHost(t *testing.T) {
pkt, err := mysql.BuildAccessDeniedErr("canary_abc", "203.0.113.7")
require.NoError(t, err)
require.Contains(
t,
string(pkt),
`Access denied for user 'canary_abc'@'203.0.113.7' (using password: YES)`,
)
}
func TestBuildAccessDeniedErr_SequenceIDIs2(t *testing.T) {
pkt, err := mysql.BuildAccessDeniedErr("u", "h")
require.NoError(t, err)
require.Equal(
t,
byte(0x02),
pkt[3],
"ERR packet sequence ID is 2 (after handshake=0 and client auth=1)",
)
}
func TestRoundTrip_BuildAndParseUsername(t *testing.T) {
cases := []string{
"canary_abcdef0123",
"canary_xyz",
"root",
"",
"app_user",
}
for _, name := range cases {
name := name
t.Run("username="+name, func(t *testing.T) {
pkt := buildHandshakeResponse41(t, name)
auth, err := mysql.ReadClientAuth(bytes.NewReader(pkt))
require.NoError(t, err)
require.Equal(t, name, auth.Username)
})
}
}
func TestNewRandomAuthData_ReturnsTwentyBytes(t *testing.T) {
d, err := mysql.NewRandomAuthData()
require.NoError(t, err)
require.Len(t, d, 20)
}
func TestNewRandomAuthData_DistinctCallsProduceDistinctOutputs(t *testing.T) {
seen := make(map[[20]byte]struct{})
for range 50 {
d, err := mysql.NewRandomAuthData()
require.NoError(t, err)
seen[d] = struct{}{}
}
require.Greater(
t,
len(seen),
45,
"50 calls to NewRandomAuthData should produce near-50 unique values",
)
}
func TestNewRandomConnectionID_DistinctCallsProduceDistinctOutputs(
t *testing.T,
) {
seen := make(map[uint32]struct{})
for range 50 {
id, err := mysql.NewRandomConnectionID()
require.NoError(t, err)
seen[id] = struct{}{}
}
require.Greater(t, len(seen), 45)
}
func TestErrors_AreDistinctSentinels(t *testing.T) {
require.NotErrorIs(t, mysql.ErrShortPacket, mysql.ErrPacketTooLarge)
require.NotErrorIs(t, mysql.ErrPacketTooLarge, mysql.ErrInvalidPayload)
require.NotErrorIs(t, mysql.ErrInvalidPayload, mysql.ErrUsernameMissing)
}