Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
169 changes: 169 additions & 0 deletions backend/cmd/server/gba_signature_advisory_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
package main

import (
"strings"
"testing"
)

// buildGBAPayloadWithSignature returns a 32K FLASH-erased buffer with mGBA's
// SRAM_V signature embedded at a low offset, mimicking what standalone mGBA
// would write.
func buildGBAPayloadWithSignature(size int, signature string) []byte {
payload := make([]byte, size)
for idx := range payload {
payload[idx] = 0xFF
}
// Embed signature at byte 64 — mGBA writes its version string at a
// well-defined offset, but for validator purposes any location within
// the first 512KB scan window works.
copy(payload[64:], []byte(signature))
// Add some non-zero bytes so the blank check passes.
for i := 0; i < 32; i++ {
payload[i] = 0x5a
}
return payload
}

// buildGBAPayloadNoSignature returns a same-size buffer with NO library
// signature and NO AGB cartridge header — simulating what RetroArch's
// libretro-mGBA core writes for Pokemon Emerald, EA-Sports 007 games, etc.
func buildGBAPayloadNoSignature(size int) []byte {
payload := make([]byte, size)
for idx := range payload {
payload[idx] = 0xFF
}
// Real-world EA Sports 007 save header (BOND0053 backwards) at offset 0 —
// guarantees no signature/AGB header collisions.
copy(payload[0:8], []byte("3500DNOB"))
// Some non-zero bytes scattered through so the blank check passes.
for i := 0; i < 32; i++ {
payload[i] = 0x5a
}
return payload
}

// Regression guard: GBA saves WITH the standard library signature must
// continue to pass without any advisory downgrade (no warning emitted).
func TestGBARawSaveAcceptedWithLibrarySignature(t *testing.T) {
a := &app{}
result := a.normalizeSaveInputDetailed(saveCreateInput{
Filename: "Some Standalone mGBA Save.srm",
Payload: buildGBAPayloadWithSignature(32768, "SRAM_V113"),
Game: game{Name: "Some Standalone mGBA Save"},
Format: "sram",
ROMSHA1: "abc123",
SlotName: "default",
SystemSlug: "gba",
TrustedHelperSystem: true,
})
if result.Rejected {
t.Fatalf("expected GBA save with SRAM_V signature to be accepted, got reject=%q", result.RejectReason)
}
if result.Input.Inspection == nil {
t.Fatal("expected GBA inspection metadata")
}
for _, w := range result.Input.Inspection.Warnings {
if strings.Contains(w, "without the standard library signature footer") {
t.Errorf("did NOT expect advisory-downgrade warning when signature is present: %q", w)
}
}
}

// THE FIX: a GBA save from a trusted helper (e.g. RetroArch via SGM-Helper),
// with rom_sha1 present and non-blank payload, but NO library signature,
// must be accepted — with a warning explaining the advisory downgrade.
func TestGBARawSaveAcceptedWithoutSignatureUnderHelperTrust(t *testing.T) {
a := &app{}
result := a.normalizeSaveInputDetailed(saveCreateInput{
Filename: "Pokemon - Emerald Version (USA, Europe).srm",
Payload: buildGBAPayloadNoSignature(131072),
Game: game{Name: "Pokemon - Emerald Version"},
Format: "flash",
ROMSHA1: "pokemon-emerald-rom-sha1",
SlotName: "default",
SystemSlug: "gba",
TrustedHelperSystem: true,
})
if result.Rejected {
t.Fatalf("expected GBA save from trusted helper without signature to be ACCEPTED, got reject=%q", result.RejectReason)
}
if result.Input.Inspection == nil {
t.Fatal("expected GBA inspection metadata")
}
foundWarning := false
for _, w := range result.Input.Inspection.Warnings {
if strings.Contains(w, "without the standard library signature footer") {
foundWarning = true
}
}
if !foundWarning {
t.Errorf("expected advisory-downgrade warning, got warnings: %+v", result.Input.Inspection.Warnings)
}
}

// Security guard: same scenario as above BUT without rom_sha1 — must reject.
// The advisory downgrade requires all three trust conditions; missing any
// one of them keeps the original reject behavior.
func TestGBARawSaveRejectedWithoutROMSHA1EvenWithHelperTrust(t *testing.T) {
a := &app{}
result := a.normalizeSaveInputDetailed(saveCreateInput{
Filename: "Pokemon - Emerald Version (USA, Europe).srm",
Payload: buildGBAPayloadNoSignature(131072),
Game: game{Name: "Pokemon - Emerald Version"},
Format: "flash",
SlotName: "default",
SystemSlug: "gba",
TrustedHelperSystem: true,
// rom_sha1 deliberately absent
})
if !result.Rejected {
t.Fatal("expected GBA save without rom_sha1 to be rejected even with helper trust")
}
// Pre-signature check (RequireROMSHA1) fires first.
if !strings.Contains(result.RejectReason, "rom_sha1") {
t.Errorf("expected rom_sha1 rejection reason, got %q", result.RejectReason)
}
}

// Security guard: helper-trust signal absent — even with rom_sha1 and
// non-blank payload, must reject. The advisory downgrade is opt-in via
// trusted authenticated channel.
func TestGBARawSaveRejectedWithoutHelperTrustEvenWithROMSHA1(t *testing.T) {
a := &app{}
result := a.normalizeSaveInputDetailed(saveCreateInput{
Filename: "Pokemon - Emerald Version (USA, Europe).srm",
Payload: buildGBAPayloadNoSignature(131072),
Game: game{Name: "Pokemon - Emerald Version"},
Format: "flash",
ROMSHA1: "pokemon-emerald-rom-sha1",
SlotName: "default",
SystemSlug: "gba",
// TrustedHelperSystem deliberately false
})
if !result.Rejected {
t.Fatal("expected GBA save without helper trust to be rejected even with rom_sha1")
}
}

// Security guard: blank payload must still be rejected — the advisory
// downgrade explicitly requires non-blank.
func TestGBARawSaveRejectedWhenBlankEvenUnderHelperTrust(t *testing.T) {
blankFF := make([]byte, 131072)
for idx := range blankFF {
blankFF[idx] = 0xFF
}
a := &app{}
result := a.normalizeSaveInputDetailed(saveCreateInput{
Filename: "freshly-launched-game.srm",
Payload: blankFF,
Game: game{Name: "freshly launched game"},
Format: "flash",
ROMSHA1: "some-rom-sha1",
SlotName: "default",
SystemSlug: "gba",
TrustedHelperSystem: true,
})
if !result.Rejected {
t.Fatal("expected blank GBA save to be rejected even under helper trust")
}
}
38 changes: 21 additions & 17 deletions backend/cmd/server/nintendo_validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,14 @@ func validateNintendoRawSave(input saveCreateInput, detection saveSystemDetectio
switch systemSlug {
case "gameboy":
return validateStrictRawSaveClass(input, detection, strictRawSaveValidationProfile{
SystemSlug: "gameboy",
DisplayName: "game boy",
ParserID: "gameboy-raw-sram",
AllowedExts: map[string]struct{}{"sav": {}, "srm": {}, "ram": {}, "rtc": {}, "gme": {}},
AllowedSizes: strictRawGBSizes,
SystemSlug: "gameboy",
DisplayName: "game boy",
ParserID: "gameboy-raw-sram",
AllowedExts: map[string]struct{}{"sav": {}, "srm": {}, "ram": {}, "rtc": {}, "gme": {}},
AllowedSizes: strictRawGBSizes,
AllowedSizesByExt: map[string]func(int) bool{
"rtc": func(n int) bool { return n >= 1 && n <= 64 },
},
RequireROMSHA1: true,
RequireTrustedMatch: true,
RejectBlank: true,
Expand All @@ -66,18 +69,19 @@ func validateNintendoRawSave(input saveCreateInput, detection saveSystemDetectio
})
case "gba":
return validateStrictRawSaveClass(input, detection, strictRawSaveValidationProfile{
SystemSlug: "gba",
DisplayName: "gba",
ParserID: "gba-raw-backup",
AllowedExts: map[string]struct{}{"sav": {}, "srm": {}, "sa1": {}},
AllowedSizes: strictRawGBASizes,
RequireROMSHA1: true,
RequireTrustedMatch: true,
RequireSignature: hasGBASignature,
SignatureReason: "gba validated payload signature",
RejectBlank: true,
SparseWarningCutoff: 16,
Warning: "No structural GBA save decoder is available yet beyond backup-library signature validation",
SystemSlug: "gba",
DisplayName: "gba",
ParserID: "gba-raw-backup",
AllowedExts: map[string]struct{}{"sav": {}, "srm": {}, "sa1": {}},
AllowedSizes: strictRawGBASizes,
RequireROMSHA1: true,
RequireTrustedMatch: true,
RequireSignature: hasGBASignature,
SignatureReason: "gba validated payload signature",
SignatureAdvisoryWithHelperTrust: true,
RejectBlank: true,
SparseWarningCutoff: 16,
Warning: "No structural GBA save decoder is available yet beyond backup-library signature validation",
})
case "nes":
return validateStrictRawSaveClass(input, detection, strictRawSaveValidationProfile{
Expand Down
66 changes: 66 additions & 0 deletions backend/cmd/server/nintendo_validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,72 @@ func TestNormalizeSaveInputRejectsBlankTrustedNeoGeoSave(t *testing.T) {
}
}

func TestNormalizeSaveInputAcceptsTinyGameBoyRTCFile(t *testing.T) {
a := &app{}
for _, size := range []int{8, 13, 32, 48, 64} {
payload := buildNonBlankPayload(size, 0xA5)
result := a.normalizeSaveInputDetailed(saveCreateInput{
Filename: "Pokemon - Crystal Version (USA, Europe) (Rev 1).rtc",
Payload: payload,
Game: game{Name: "Pokemon - Crystal Version"},
Format: "sram",
ROMSHA1: "pokemon-crystal-rom",
SlotName: "default",
SystemSlug: "gameboy",
TrustedHelperSystem: true,
})
if result.Rejected {
t.Fatalf("expected gameboy .rtc payload of %d bytes to be accepted, got reject=%q", size, result.RejectReason)
}
if result.Input.Inspection == nil || result.Input.Inspection.ParserID != "gameboy-raw-sram" {
t.Fatalf("expected gameboy-raw-sram inspection for %d-byte .rtc, got %+v", size, result.Input.Inspection)
}
if got := result.Input.Inspection.SemanticFields["rawSaveKind"]; got != "Game Boy real-time clock data" {
t.Fatalf("expected RTC raw save kind for %d-byte .rtc, got %+v", size, result.Input.Inspection.SemanticFields)
}
}
}

func TestNormalizeSaveInputRejectsTinyGameBoySRMFile(t *testing.T) {
a := &app{}
result := a.normalizeSaveInputDetailed(saveCreateInput{
Filename: "Pokemon - Crystal Version (USA, Europe) (Rev 1).srm",
Payload: buildNonBlankPayload(8, 0xA5),
Game: game{Name: "Pokemon - Crystal Version"},
Format: "sram",
ROMSHA1: "pokemon-crystal-rom",
SlotName: "default",
SystemSlug: "gameboy",
TrustedHelperSystem: true,
})
if !result.Rejected {
t.Fatal("expected 8-byte gameboy .srm payload to still be rejected (regression guard)")
}
if result.RejectReason != "game boy raw save size 8 is not recognized" {
t.Fatalf("unexpected reject reason: %q", result.RejectReason)
}
}

func TestNormalizeSaveInputRejectsOversizedGameBoyRTCFile(t *testing.T) {
a := &app{}
result := a.normalizeSaveInputDetailed(saveCreateInput{
Filename: "Pokemon - Crystal Version (USA, Europe) (Rev 1).rtc",
Payload: buildNonBlankPayload(65, 0xA5),
Game: game{Name: "Pokemon - Crystal Version"},
Format: "sram",
ROMSHA1: "pokemon-crystal-rom",
SlotName: "default",
SystemSlug: "gameboy",
TrustedHelperSystem: true,
})
if !result.Rejected {
t.Fatal("expected 65-byte gameboy .rtc payload to be rejected (oversized for RTC)")
}
if result.RejectReason != "game boy raw save size 65 is not recognized for .rtc" {
t.Fatalf("unexpected reject reason: %q", result.RejectReason)
}
}

func buildNSMBNDSSaveFixture() []byte {
payload := make([]byte, 8192)
positions := []int{2, 258, 898, 1538, 2178, 4098, 4354, 4994, 5634, 6274}
Expand Down
60 changes: 51 additions & 9 deletions backend/cmd/server/raw_save_validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,26 @@ type strictRawSaveValidationProfile struct {
ParserID string
AllowedExts map[string]struct{}
AllowedSizes map[int]struct{}
AllowedSizesByExt map[string]func(int) bool
RequireROMSHA1 bool
RequireTrustedMatch bool
RequireDeclared bool
RequireHelperOrStore bool
RequireSignature func([]byte) bool
SignatureReason string
RejectBlank bool
SparseWarningCutoff int
Warning string
// SignatureAdvisoryWithHelperTrust: when true, a failing RequireSignature check
// is downgraded from reject to warning IF (a) detection.Evidence.HelperTrusted
// is true AND (b) rom_sha1 is present AND (c) the payload is non-blank. This
// enables saves from authenticated helpers whose underlying emulators don't
// embed the library signature footer the validator would prefer to see — most
// notably RetroArch's libretro-mGBA core, which is the dominant modern GBA
// emulation path (Steam Deck / RetroDECK) and does NOT write the
// EEPROM_V/SRAM_V/FLASH_V/FLASH1M_V/FLASH512_V strings that standalone
// mGBA and VBA-M do. See issue #7.
SignatureAdvisoryWithHelperTrust bool
RejectBlank bool
SparseWarningCutoff int
Warning string
}

func validateStrictRawSaveClass(input saveCreateInput, detection saveSystemDetectionResult, profile strictRawSaveValidationProfile) consoleValidationResult {
Expand Down Expand Up @@ -62,7 +73,14 @@ func validateStrictRawSaveClass(input saveCreateInput, detection saveSystemDetec
RejectReason: "payload looks like text/noise",
}
}
if _, ok := profile.AllowedSizes[len(input.Payload)]; !ok {
if extSizeFn, ok := profile.AllowedSizesByExt[ext]; ok {
if !extSizeFn(len(input.Payload)) {
return consoleValidationResult{
Rejected: true,
RejectReason: fmt.Sprintf("%s raw save size %d is not recognized for .%s", profile.DisplayName, len(input.Payload), ext),
}
}
} else if _, ok := profile.AllowedSizes[len(input.Payload)]; !ok {
return consoleValidationResult{
Rejected: true,
RejectReason: fmt.Sprintf("%s raw save size %d is not recognized", profile.DisplayName, len(input.Payload)),
Expand Down Expand Up @@ -92,14 +110,27 @@ func validateStrictRawSaveClass(input saveCreateInput, detection saveSystemDetec
RejectReason: fmt.Sprintf("%s raw saves require trusted helper or stored system evidence", profile.SystemSlug),
}
}
signatureAdvisoryDowngraded := false
stats := analyzeRawSavePayload(input.Payload, profile.SparseWarningCutoff)
if profile.RequireSignature != nil && !profile.RequireSignature(input.Payload) {
return consoleValidationResult{
Rejected: true,
RejectReason: fmt.Sprintf("%s raw save is missing a validated payload signature", profile.DisplayName),
// Optional escape hatch: if the profile allows the signature requirement
// to be advisory under sufficient trust, and ALL of the trust conditions
// hold (HelperTrusted + rom_sha1 present + non-blank payload), downgrade
// the rejection to a warning instead. The signature stays a hard reject
// for anonymous uploads or uploads without rom_sha1.
canDowngrade := profile.SignatureAdvisoryWithHelperTrust &&
detection.Evidence.HelperTrusted &&
strings.TrimSpace(input.ROMSHA1) != "" &&
!stats.BlankZero && !stats.BlankFF
if !canDowngrade {
return consoleValidationResult{
Rejected: true,
RejectReason: fmt.Sprintf("%s raw save is missing a validated payload signature", profile.DisplayName),
}
}
signatureAdvisoryDowngraded = true
}

stats := analyzeRawSavePayload(input.Payload, profile.SparseWarningCutoff)
if profile.RejectBlank && stats.BlankZero {
return consoleValidationResult{
Rejected: true,
Expand Down Expand Up @@ -135,13 +166,24 @@ func validateStrictRawSaveClass(input saveCreateInput, detection saveSystemDetec
evidence = append(evidence, "declared system")
}
if profile.RequireSignature != nil && strings.TrimSpace(profile.SignatureReason) != "" {
evidence = append(evidence, profile.SignatureReason)
if signatureAdvisoryDowngraded {
evidence = append(evidence, profile.SignatureReason+" (advisory: missing — accepted under helper trust)")
} else {
evidence = append(evidence, profile.SignatureReason)
}
}

warnings := []string(nil)
if strings.TrimSpace(profile.Warning) != "" {
warnings = append(warnings, profile.Warning)
}
if signatureAdvisoryDowngraded {
warnings = append(warnings,
fmt.Sprintf("%s save accepted without the standard library signature footer; "+
"trust derived from authenticated helper + rom_sha1 + non-blank payload. "+
"Common for RetroArch / libretro cores that don't write the footer.",
profile.DisplayName))
}
if stats.SparseCutoff > 0 && stats.NonZero <= stats.SparseCutoff {
warnings = append(warnings, "Payload is extremely sparse and only raw media validation is available")
}
Expand Down