feat(protbot): add headless MHF protocol bot as cmd/protbot

Copy MHBridge into the Erupe module as cmd/protbot/ so it can be
built, tested, and maintained alongside the server. The bot
implements the full sign → entrance → channel login flow and
supports lobby entry, chat, session setup, and quest enumeration.

The conn/ package keeps its own Blowfish crypto primitives to avoid
importing erupe-ce/config (which requires a config file at init).
This commit is contained in:
Houmgaor
2026-02-20 02:49:23 +01:00
parent 754b5a3bff
commit 0e84377e21
19 changed files with 2106 additions and 0 deletions

37
cmd/protbot/conn/bin8.go Normal file
View File

@@ -0,0 +1,37 @@
package conn
import "encoding/binary"
var (
bin8Key = []byte{0x01, 0x23, 0x34, 0x45, 0x56, 0xAB, 0xCD, 0xEF}
sum32Table0 = []byte{0x35, 0x7A, 0xAA, 0x97, 0x53, 0x66, 0x12}
sum32Table1 = []byte{0x7A, 0xAA, 0x97, 0x53, 0x66, 0x12, 0xDE, 0xDE, 0x35}
)
// CalcSum32 calculates the custom MHF "sum32" checksum.
func CalcSum32(data []byte) uint32 {
tableIdx0 := (len(data) + 1) & 0xFF
tableIdx1 := int((data[len(data)>>1] + 1) & 0xFF)
out := make([]byte, 4)
for i := 0; i < len(data); i++ {
key := data[i] ^ sum32Table0[(tableIdx0+i)%7] ^ sum32Table1[(tableIdx1+i)%9]
out[i&3] = (out[i&3] + key) & 0xFF
}
return binary.BigEndian.Uint32(out)
}
func rotate(k *uint32) {
*k = uint32(((54323 * uint(*k)) + 1) & 0xFFFFFFFF)
}
// DecryptBin8 decrypts MHF "binary8" data.
func DecryptBin8(data []byte, key byte) []byte {
k := uint32(key)
output := make([]byte, len(data))
for i := 0; i < len(data); i++ {
rotate(&k)
tmp := data[i] ^ byte((k>>13)&0xFF)
output[i] = tmp ^ bin8Key[i&7]
}
return output
}

View File

@@ -0,0 +1,52 @@
package conn
import (
"testing"
)
// TestCalcSum32 verifies the checksum against a known input.
func TestCalcSum32(t *testing.T) {
// Verify determinism: same input gives same output.
data := []byte("Hello, MHF!")
sum1 := CalcSum32(data)
sum2 := CalcSum32(data)
if sum1 != sum2 {
t.Fatalf("CalcSum32 not deterministic: %08X != %08X", sum1, sum2)
}
// Different inputs produce different outputs (basic sanity).
data2 := []byte("Hello, MHF?")
sum3 := CalcSum32(data2)
if sum1 == sum3 {
t.Fatalf("CalcSum32 collision on different inputs: both %08X", sum1)
}
}
// TestDecryptBin8RoundTrip verifies that encrypting and decrypting with Bin8
// produces the original data. We only have DecryptBin8, but we can verify
// the encrypt→decrypt path by implementing encrypt inline here.
func TestDecryptBin8RoundTrip(t *testing.T) {
original := []byte("Test data for Bin8 encryption round-trip")
key := byte(0x42)
// Encrypt (inline copy of Erupe's EncryptBin8)
k := uint32(key)
encrypted := make([]byte, len(original))
for i := 0; i < len(original); i++ {
rotate(&k)
tmp := bin8Key[i&7] ^ byte((k>>13)&0xFF)
encrypted[i] = original[i] ^ tmp
}
// Decrypt
decrypted := DecryptBin8(encrypted, key)
if len(decrypted) != len(original) {
t.Fatalf("length mismatch: got %d, want %d", len(decrypted), len(original))
}
for i := range original {
if decrypted[i] != original[i] {
t.Fatalf("byte %d: got 0x%02X, want 0x%02X", i, decrypted[i], original[i])
}
}
}

52
cmd/protbot/conn/conn.go Normal file
View File

@@ -0,0 +1,52 @@
package conn
import (
"fmt"
"net"
)
// MHFConn wraps a CryptConn and provides convenience methods for MHF connections.
type MHFConn struct {
*CryptConn
RawConn net.Conn
}
// DialWithInit connects to addr and sends the 8 NULL byte initialization
// required by sign and entrance servers.
func DialWithInit(addr string) (*MHFConn, error) {
conn, err := net.Dial("tcp", addr)
if err != nil {
return nil, fmt.Errorf("dial %s: %w", addr, err)
}
// Sign and entrance servers expect 8 NULL bytes to initialize the connection.
_, err = conn.Write(make([]byte, 8))
if err != nil {
conn.Close()
return nil, fmt.Errorf("write init bytes to %s: %w", addr, err)
}
return &MHFConn{
CryptConn: NewCryptConn(conn),
RawConn: conn,
}, nil
}
// DialDirect connects to addr without sending initialization bytes.
// Used for channel server connections.
func DialDirect(addr string) (*MHFConn, error) {
conn, err := net.Dial("tcp", addr)
if err != nil {
return nil, fmt.Errorf("dial %s: %w", addr, err)
}
return &MHFConn{
CryptConn: NewCryptConn(conn),
RawConn: conn,
}, nil
}
// Close closes the underlying connection.
func (c *MHFConn) Close() error {
return c.RawConn.Close()
}

View File

@@ -0,0 +1,115 @@
package conn
import (
"encoding/hex"
"errors"
"erupe-ce/network/crypto"
"fmt"
"io"
"net"
)
// CryptConn is an MHF encrypted two-way connection.
// Adapted from Erupe's network/crypt_conn.go with config dependency removed.
// Hardcoded to ZZ mode (supports Pf0-based extended data size).
type CryptConn struct {
conn net.Conn
readKeyRot uint32
sendKeyRot uint32
sentPackets int32
prevRecvPacketCombinedCheck uint16
prevSendPacketCombinedCheck uint16
}
// NewCryptConn creates a new CryptConn with proper default values.
func NewCryptConn(conn net.Conn) *CryptConn {
return &CryptConn{
conn: conn,
readKeyRot: 995117,
sendKeyRot: 995117,
}
}
// ReadPacket reads a packet from the connection and returns the decrypted data.
func (cc *CryptConn) ReadPacket() ([]byte, error) {
headerData := make([]byte, CryptPacketHeaderLength)
_, err := io.ReadFull(cc.conn, headerData)
if err != nil {
return nil, err
}
cph, err := NewCryptPacketHeader(headerData)
if err != nil {
return nil, err
}
// ZZ mode: extended data size using Pf0 field.
encryptedPacketBody := make([]byte, uint32(cph.DataSize)+(uint32(cph.Pf0-0x03)*0x1000))
_, err = io.ReadFull(cc.conn, encryptedPacketBody)
if err != nil {
return nil, err
}
if cph.KeyRotDelta != 0 {
cc.readKeyRot = uint32(cph.KeyRotDelta) * (cc.readKeyRot + 1)
}
out, combinedCheck, check0, check1, check2 := crypto.Crypto(encryptedPacketBody, cc.readKeyRot, false, nil)
if cph.Check0 != check0 || cph.Check1 != check1 || cph.Check2 != check2 {
fmt.Printf("got c0 %X, c1 %X, c2 %X\n", check0, check1, check2)
fmt.Printf("want c0 %X, c1 %X, c2 %X\n", cph.Check0, cph.Check1, cph.Check2)
fmt.Printf("headerData:\n%s\n", hex.Dump(headerData))
fmt.Printf("encryptedPacketBody:\n%s\n", hex.Dump(encryptedPacketBody))
// Attempt bruteforce recovery.
fmt.Println("Crypto out of sync? Attempting bruteforce")
for key := byte(0); key < 255; key++ {
out, combinedCheck, check0, check1, check2 = crypto.Crypto(encryptedPacketBody, 0, false, &key)
if cph.Check0 == check0 && cph.Check1 == check1 && cph.Check2 == check2 {
fmt.Printf("Bruteforce successful, override key: 0x%X\n", key)
cc.prevRecvPacketCombinedCheck = combinedCheck
return out, nil
}
}
return nil, errors.New("decrypted data checksum doesn't match header")
}
cc.prevRecvPacketCombinedCheck = combinedCheck
return out, nil
}
// SendPacket encrypts and sends a packet.
func (cc *CryptConn) SendPacket(data []byte) error {
keyRotDelta := byte(3)
if keyRotDelta != 0 {
cc.sendKeyRot = uint32(keyRotDelta) * (cc.sendKeyRot + 1)
}
encData, combinedCheck, check0, check1, check2 := crypto.Crypto(data, cc.sendKeyRot, true, nil)
header := &CryptPacketHeader{}
header.Pf0 = byte(((uint(len(encData)) >> 12) & 0xF3) | 3)
header.KeyRotDelta = keyRotDelta
header.PacketNum = uint16(cc.sentPackets)
header.DataSize = uint16(len(encData))
header.PrevPacketCombinedCheck = cc.prevSendPacketCombinedCheck
header.Check0 = check0
header.Check1 = check1
header.Check2 = check2
headerBytes, err := header.Encode()
if err != nil {
return err
}
_, err = cc.conn.Write(append(headerBytes, encData...))
if err != nil {
return err
}
cc.sentPackets++
cc.prevSendPacketCombinedCheck = combinedCheck
return nil
}

View File

@@ -0,0 +1,152 @@
package conn
import (
"io"
"net"
"testing"
)
// TestCryptConnRoundTrip verifies that encrypting and decrypting a packet
// through a pair of CryptConn instances produces the original data.
func TestCryptConnRoundTrip(t *testing.T) {
// Create an in-process TCP pipe.
server, client := net.Pipe()
defer server.Close()
defer client.Close()
sender := NewCryptConn(client)
receiver := NewCryptConn(server)
testCases := [][]byte{
{0x00, 0x14, 0x00, 0x00, 0x00, 0x01}, // Minimal login-like packet
{0xDE, 0xAD, 0xBE, 0xEF},
make([]byte, 256), // Larger packet
}
for i, original := range testCases {
// Send in a goroutine to avoid blocking.
errCh := make(chan error, 1)
go func() {
errCh <- sender.SendPacket(original)
}()
received, err := receiver.ReadPacket()
if err != nil {
t.Fatalf("case %d: ReadPacket error: %v", i, err)
}
if err := <-errCh; err != nil {
t.Fatalf("case %d: SendPacket error: %v", i, err)
}
if len(received) != len(original) {
t.Fatalf("case %d: length mismatch: got %d, want %d", i, len(received), len(original))
}
for j := range original {
if received[j] != original[j] {
t.Fatalf("case %d: byte %d mismatch: got 0x%02X, want 0x%02X", i, j, received[j], original[j])
}
}
}
}
// TestCryptPacketHeaderRoundTrip verifies header encode/decode.
func TestCryptPacketHeaderRoundTrip(t *testing.T) {
original := &CryptPacketHeader{
Pf0: 0x03,
KeyRotDelta: 0x03,
PacketNum: 42,
DataSize: 100,
PrevPacketCombinedCheck: 0x1234,
Check0: 0xAAAA,
Check1: 0xBBBB,
Check2: 0xCCCC,
}
encoded, err := original.Encode()
if err != nil {
t.Fatalf("Encode error: %v", err)
}
if len(encoded) != CryptPacketHeaderLength {
t.Fatalf("encoded length: got %d, want %d", len(encoded), CryptPacketHeaderLength)
}
decoded, err := NewCryptPacketHeader(encoded)
if err != nil {
t.Fatalf("NewCryptPacketHeader error: %v", err)
}
if *decoded != *original {
t.Fatalf("header mismatch:\ngot %+v\nwant %+v", *decoded, *original)
}
}
// TestMultiPacketSequence verifies that key rotation stays in sync across
// multiple sequential packets.
func TestMultiPacketSequence(t *testing.T) {
server, client := net.Pipe()
defer server.Close()
defer client.Close()
sender := NewCryptConn(client)
receiver := NewCryptConn(server)
for i := 0; i < 10; i++ {
data := []byte{byte(i), byte(i + 1), byte(i + 2), byte(i + 3)}
errCh := make(chan error, 1)
go func() {
errCh <- sender.SendPacket(data)
}()
received, err := receiver.ReadPacket()
if err != nil {
t.Fatalf("packet %d: ReadPacket error: %v", i, err)
}
if err := <-errCh; err != nil {
t.Fatalf("packet %d: SendPacket error: %v", i, err)
}
for j := range data {
if received[j] != data[j] {
t.Fatalf("packet %d byte %d: got 0x%02X, want 0x%02X", i, j, received[j], data[j])
}
}
}
}
// TestDialWithInit verifies that DialWithInit sends 8 NULL bytes on connect.
func TestDialWithInit(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer listener.Close()
done := make(chan []byte, 1)
go func() {
conn, err := listener.Accept()
if err != nil {
return
}
defer conn.Close()
buf := make([]byte, 8)
_, _ = io.ReadFull(conn, buf)
done <- buf
}()
c, err := DialWithInit(listener.Addr().String())
if err != nil {
t.Fatal(err)
}
defer c.Close()
initBytes := <-done
for i, b := range initBytes {
if b != 0 {
t.Fatalf("init byte %d: got 0x%02X, want 0x00", i, b)
}
}
}

View File

@@ -0,0 +1,78 @@
// Package conn provides MHF encrypted connection primitives.
//
// This is adapted from Erupe's network/crypt_packet.go to avoid importing
// erupe-ce/config (whose init() calls os.Exit without a config file).
package conn
import (
"bytes"
"encoding/binary"
)
const CryptPacketHeaderLength = 14
// CryptPacketHeader represents the parsed information of an encrypted packet header.
type CryptPacketHeader struct {
Pf0 byte
KeyRotDelta byte
PacketNum uint16
DataSize uint16
PrevPacketCombinedCheck uint16
Check0 uint16
Check1 uint16
Check2 uint16
}
// NewCryptPacketHeader parses raw bytes into a CryptPacketHeader.
func NewCryptPacketHeader(data []byte) (*CryptPacketHeader, error) {
var c CryptPacketHeader
r := bytes.NewReader(data)
if err := binary.Read(r, binary.BigEndian, &c.Pf0); err != nil {
return nil, err
}
if err := binary.Read(r, binary.BigEndian, &c.KeyRotDelta); err != nil {
return nil, err
}
if err := binary.Read(r, binary.BigEndian, &c.PacketNum); err != nil {
return nil, err
}
if err := binary.Read(r, binary.BigEndian, &c.DataSize); err != nil {
return nil, err
}
if err := binary.Read(r, binary.BigEndian, &c.PrevPacketCombinedCheck); err != nil {
return nil, err
}
if err := binary.Read(r, binary.BigEndian, &c.Check0); err != nil {
return nil, err
}
if err := binary.Read(r, binary.BigEndian, &c.Check1); err != nil {
return nil, err
}
if err := binary.Read(r, binary.BigEndian, &c.Check2); err != nil {
return nil, err
}
return &c, nil
}
// Encode encodes the CryptPacketHeader into raw bytes.
func (c *CryptPacketHeader) Encode() ([]byte, error) {
buf := bytes.NewBuffer([]byte{})
data := []interface{}{
c.Pf0,
c.KeyRotDelta,
c.PacketNum,
c.DataSize,
c.PrevPacketCombinedCheck,
c.Check0,
c.Check1,
c.Check2,
}
for _, v := range data {
if err := binary.Write(buf, binary.BigEndian, v); err != nil {
return nil, err
}
}
return buf.Bytes(), nil
}