From 712202abc0a213ad355b07567cace5f0a95c598e Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Wed, 13 May 2026 14:49:03 -0400 Subject: [PATCH] feat(canary): mysql wire protocol (handshake + auth + ERR packets) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../token/generators/mysql/protocol.go | 220 ++++++++++++ .../token/generators/mysql/protocol_test.go | 329 ++++++++++++++++++ 2 files changed, 549 insertions(+) create mode 100644 PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/mysql/protocol.go create mode 100644 PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/mysql/protocol_test.go diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/mysql/protocol.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/mysql/protocol.go new file mode 100644 index 00000000..35415b6b --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/mysql/protocol.go @@ -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 +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/mysql/protocol_test.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/mysql/protocol_test.go new file mode 100644 index 00000000..7a0b8f2f --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/mysql/protocol_test.go @@ -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) +}