mirror of
https://github.com/Mezeporta/Erupe.git
synced 2026-03-27 10:03:06 +01:00
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:
37
cmd/protbot/conn/bin8.go
Normal file
37
cmd/protbot/conn/bin8.go
Normal 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
|
||||
}
|
||||
52
cmd/protbot/conn/bin8_test.go
Normal file
52
cmd/protbot/conn/bin8_test.go
Normal 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
52
cmd/protbot/conn/conn.go
Normal 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()
|
||||
}
|
||||
115
cmd/protbot/conn/crypt_conn.go
Normal file
115
cmd/protbot/conn/crypt_conn.go
Normal 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
|
||||
}
|
||||
152
cmd/protbot/conn/crypt_conn_test.go
Normal file
152
cmd/protbot/conn/crypt_conn_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
78
cmd/protbot/conn/crypt_packet.go
Normal file
78
cmd/protbot/conn/crypt_packet.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user