diff --git a/CHANGELOG.md b/CHANGELOG.md index 16453b03a..147288881 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- `DisableSaveIntegrityCheck` config flag: when `true`, the SHA-256 savedata integrity check is skipped on load. Intended for cross-server save transfers where the stored hash in the database does not match the imported save blob. Defaults to `false`. Affected characters can alternatively be unblocked per-character with `UPDATE characters SET savedata_hash = NULL WHERE id = `. + ## [9.3.0] - 2026-03-19 ### Fixed diff --git a/config.reference.json b/config.reference.json index 0e1270e88..678370d6f 100644 --- a/config.reference.json +++ b/config.reference.json @@ -17,6 +17,7 @@ "UploadQuality":100 }, "DeleteOnSaveCorruption": false, + "DisableSaveIntegrityCheck": false, "ClientMode": "ZZ", "QuestCacheExpiry": 300, "CommandPrefix": "!", diff --git a/config/config.go b/config/config.go index 8555fe701..b731b45b0 100644 --- a/config/config.go +++ b/config/config.go @@ -72,7 +72,8 @@ type Config struct { LoginNotices []string // MHFML string of the login notices displayed PatchServerManifest string // Manifest patch server override PatchServerFile string // File patch server override - DeleteOnSaveCorruption bool // Attempts to save corrupted data will flag the save for deletion + DeleteOnSaveCorruption bool // Attempts to save corrupted data will flag the save for deletion + DisableSaveIntegrityCheck bool // Skip SHA-256 hash verification on load (needed for cross-server save transfers) ClientMode string RealClientMode Mode QuestCacheExpiry int // Number of seconds to keep quest data cached diff --git a/config/config_load_test.go b/config/config_load_test.go index d19359edc..e88e3bf7a 100644 --- a/config/config_load_test.go +++ b/config/config_load_test.go @@ -150,7 +150,8 @@ func TestConfigStruct(t *testing.T) { LoginNotices: []string{"Welcome"}, PatchServerManifest: "http://patch.example.com/manifest", PatchServerFile: "http://patch.example.com/files", - DeleteOnSaveCorruption: false, + DeleteOnSaveCorruption: false, + DisableSaveIntegrityCheck: false, ClientMode: "ZZ", RealClientMode: ZZ, QuestCacheExpiry: 3600, diff --git a/config/config_test.go b/config/config_test.go index 6fc66f06b..65951fd6c 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -157,7 +157,8 @@ func TestConfigStructTypes(t *testing.T) { LoginNotices: []string{"Notice"}, PatchServerManifest: "http://patch.example.com", PatchServerFile: "http://files.example.com", - DeleteOnSaveCorruption: false, + DeleteOnSaveCorruption: false, + DisableSaveIntegrityCheck: false, ClientMode: "ZZ", RealClientMode: ZZ, QuestCacheExpiry: 3600, diff --git a/server/channelserver/handlers_character.go b/server/channelserver/handlers_character.go index b8a84bfca..3fc8fb60b 100644 --- a/server/channelserver/handlers_character.go +++ b/server/channelserver/handlers_character.go @@ -55,7 +55,8 @@ func GetCharacterSaveData(s *Session, charID uint32) (*CharacterSaveData, error) // Verify integrity checksum if one was stored with this save. // A nil hash means the character was saved before checksums were introduced, // so we skip verification (the next save will compute and store the hash). - if storedHash != nil { + // DisableSaveIntegrityCheck bypasses this entirely for cross-server save transfers. + if storedHash != nil && !s.server.erupeConfig.DisableSaveIntegrityCheck { computedHash := sha256.Sum256(saveData.decompSave) if !bytes.Equal(storedHash, computedHash[:]) { s.logger.Error("Savedata integrity check failed: hash mismatch", @@ -66,6 +67,10 @@ func GetCharacterSaveData(s *Session, charID uint32) (*CharacterSaveData, error) // TODO: attempt recovery from savedata_backups here return nil, errors.New("savedata integrity check failed") } + } else if storedHash != nil && s.server.erupeConfig.DisableSaveIntegrityCheck { + s.logger.Warn("Savedata integrity check skipped (DisableSaveIntegrityCheck=true)", + zap.Uint32("charID", charID), + ) } saveData.updateStructWithSaveData() diff --git a/server/channelserver/handlers_character_test.go b/server/channelserver/handlers_character_test.go index 40047c7ae..102647b34 100644 --- a/server/channelserver/handlers_character_test.go +++ b/server/channelserver/handlers_character_test.go @@ -747,3 +747,68 @@ func TestGetCharacterSaveData_ConfigMode(t *testing.T) { }) } } + +// TestGetCharacterSaveData_IntegrityCheck verifies the SHA-256 hash guard and +// that DisableSaveIntegrityCheck bypasses it without returning an error. +func TestGetCharacterSaveData_IntegrityCheck(t *testing.T) { + // Build a minimal valid savedata blob and compress it. + rawSave := make([]byte, 150000) + copy(rawSave[88:], []byte("TestChar\x00")) + compressed, err := nullcomp.Compress(rawSave) + if err != nil { + t.Fatalf("compress: %v", err) + } + + // A hash that deliberately does NOT match rawSave. + wrongHash := bytes.Repeat([]byte{0xDE}, 32) + + tests := []struct { + name string + disable bool + hash []byte + wantErr bool + }{ + { + name: "nil hash skips check", + disable: false, + hash: nil, + wantErr: false, + }, + { + name: "mismatched hash fails when check enabled", + disable: false, + hash: wrongHash, + wantErr: true, + }, + { + name: "mismatched hash passes when check disabled", + disable: true, + hash: wrongHash, + wantErr: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + mock := newMockCharacterRepo() + mock.loadSaveDataID = 1 + mock.loadSaveDataData = compressed + mock.loadSaveDataName = "TestChar" + mock.loadSaveDataHash = tc.hash + + server := createMockServer() + server.erupeConfig.RealClientMode = cfg.ZZ + server.erupeConfig.DisableSaveIntegrityCheck = tc.disable + server.charRepo = mock + session := createMockSession(1, server) + + _, err := GetCharacterSaveData(session, 1) + if tc.wantErr && err == nil { + t.Error("expected error, got nil") + } + if !tc.wantErr && err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + } +} diff --git a/server/channelserver/repo_mocks_test.go b/server/channelserver/repo_mocks_test.go index dbd527669..1291c5a74 100644 --- a/server/channelserver/repo_mocks_test.go +++ b/server/channelserver/repo_mocks_test.go @@ -133,6 +133,7 @@ type mockCharacterRepo struct { loadSaveDataData []byte loadSaveDataNew bool loadSaveDataName string + loadSaveDataHash []byte loadSaveDataErr error // ReadEtcPoints mock fields @@ -245,7 +246,7 @@ func (m *mockCharacterRepo) SaveBackup(_ uint32, _ int, _ []byte) error { func (m *mockCharacterRepo) GetLastBackupTime(_ uint32) (time.Time, error) { return time.Time{}, nil } func (m *mockCharacterRepo) SaveCharacterDataAtomic(_ SaveAtomicParams) error { return nil } func (m *mockCharacterRepo) LoadSaveDataWithHash(_ uint32) (uint32, []byte, bool, string, []byte, error) { - return m.loadSaveDataID, m.loadSaveDataData, m.loadSaveDataNew, m.loadSaveDataName, nil, m.loadSaveDataErr + return m.loadSaveDataID, m.loadSaveDataData, m.loadSaveDataNew, m.loadSaveDataName, m.loadSaveDataHash, m.loadSaveDataErr } // --- mockGoocooRepo ---