func hashsha256(payload: String) -> String:
Root cause (ED-GAP-024): the save side built save_data, hashed it without save_info, then appended save_info and checksum before writing. The load side parsed the file (which now contains save_info) and hashed with save_info minus checksum. The two payloads could never be byte-identical, so the checksum always mismatched.
Fix: insert save_info into the save data before signing. Both sides then hash the same canonical payload: deep-duplicate → erase checksum → compact JSON.stringify(copy, "", false). Godot 4 Dictionary preserves insertion order through JSON.parse/JSON.stringify, and JSON.parse ignores on-disk whitespace, so save-side and load-side payloads are byte-identical.
# SaveManager.gd (Godot 4.x)
extends Node
const SAVE_PATH := "user://savegame.json"
const CHECKSUM_KEY := "checksum"
const SAVE_INFO_KEY := "save_info"
func _hash_sha256(payload: String) -> String:
var ctx := HashingContext.new()
ctx.start(HashingContext.HASH_SHA256)
ctx.update(payload.to_utf8_buffer())
return ctx.finish().hex_encode()
# The canonical payload BOTH sides agree on:
# deep-duplicate -> erase checksum -> compact JSON, insertion order preserved.
# NOTE: JSON.stringify's default sort_keys is TRUE; we pass false explicitly so
# both sides hash in insertion order (Godot 4 dicts keep it through parse/stringify).
func _canonical_string(save_data: Dictionary) -> String:
var copy: Dictionary = save_data.duplicate(true)
copy.erase(CHECKSUM_KEY)
return JSON.stringify(copy, "", false)
func _checksum(save_data: Dictionary) -> String:
return _hash_sha256(_canonical_string(save_data))
func save_game(data: Dictionary) -> Error:
# 1. Deep-duplicate: caller's dict is never mutated.
var save_data: Dictionary = data.duplicate(true)
# 2. Insert save_info BEFORE hashing (the actual fix). Old code added it
# after hashing, so save hashed WITHOUT it and load hashed WITH it.
save_data[SAVE_INFO_KEY] = {
"version": ProjectSettings.get_setting("application/config/version", "0.0.0"),
"schema": 1,
"timestamp": Time.get_datetime_string_from_system(),
}
# 3. Sign (checksum excluded from the hashed payload), then attach it.
save_data[CHECKSUM_KEY] = _checksum(save_data)
# 4. Persist. Pretty-printing is cosmetic: parse() ignores whitespace,
# so on-disk formatting never affects the hash.
var file := FileAccess.open(SAVE_PATH, FileAccess.WRITE)
if file == null:
return FileAccess.get_open_error()
file.store_string(JSON.stringify(save_data, " ", false))
file.close()
return OK
func load_game() -> Dictionary:
if not FileAccess.file_exists(SAVE_PATH):
return {}
var file := FileAccess.open(SAVE_PATH, FileAccess.READ)
if file == null:
return {}
var parsed: Variant = JSON.parse_string(file.get_as_text())
file.close()
if not (parsed is Dictionary):
push_error("Save file is not a JSON object")
return {}
var save_data: Dictionary = parsed
# Load side hashes the SAME canonical payload (duplicate minus checksum).
var expected: String = save_data.get(CHECKSUM_KEY, "")
if expected.is_empty() or not _constant_time_eq(expected, _checksum(save_data)):
push_error("Checksum mismatch: save file corrupt or tampered with")
return {}
return save_data
static func _constant_time_eq(a: String, b: String) -> bool:
if a.length() != b.length():
return false
var diff := 0
for i in a.length():
diff |= a.unicode_at(i) ^ b.unicode_at(i)
return diff == 0
Notes for your Godot environment:
- JSON.stringify(data, "", false) — the third arg is sort_keys; the default is true. Passing false deliberately relies on Godot 4's ordered dicts (per the fix spec). If you ever need order-independence instead, pass true on both sides — the invariant is that both sides use identical settings.
- Use identical full_precision (default false) on both sides so float round-trips are stable; both sides call the same _canonical_string, so this is automatic.
- Key order on save: caller's keys → save_info → checksum. On load after erasing checksum: caller's keys → save_info. Byte-identical.
Godot is not installed in this environment, so I verified by simulating the exact algorithm (ordered dicts, deep copy, erase-checksum, compact `JSON.stringify(copy, "", false)` ≈ `json.dumps(..., separators=(",", ":"))`, SHA-256) at `/tmp/edgap024/verify.py` — Python 3.7+ dicts preserve insertion order just like Godot 4, and `json.loads` ignores whitespace like `JSON.parse`.
Results (19/19 checks on the fixed code, 1 expected failure on the old code):
| Check | Result |
|---|---|
| OLD code (save_info added after hashing) → load rejected | FAIL (expected — reproduces ED-GAP-024) |
| FIXED code round trip: save → disk → load verifies | PASS |
| `save_info` is inside the signed payload (the fix itself) | PASS |
| `checksum` key excluded from the signed payload | PASS |
| Save-side canonical string == load-side canonical string (byte-identical) | PASS |
| Hashes identical across save/load | PASS |
| Compact vs. pretty-printed on-disk bytes → same verification | PASS |
| Value tampered (top-level) → rejected | PASS |
| Value tampered (nested dict/array) → rejected | PASS |
| Any byte substitution ("Kara"→"Karo") → rejected | PASS |
| Missing checksum → rejected | PASS |
| Empty dict / single key / nested-empty round trips | PASS |
| Caller's dict not mutated (no `checksum`/`save_info` injected; nested values untouched) | PASS |
| Spoofed caller-supplied `checksum`/`save_info` overwritten by real values; round trip still verifies | PASS |
Edge cases covered: empty data, nested dicts/arrays, whitespace-insensitivity of on-disk formatting, tamper detection at top-level and deep-nested, missing checksum, caller-key collisions, and deep-duplication side effects. In Godot itself, this is covered by a round-trip + tamper unit test (GUT): `SaveManager.new()` → `save_game({...})` → `load_game()` returns non-empty; flip a byte in the file → `load_game()` returns `{}` and pushes the mismatch error.
---{"model": "deepseek-v4-flash", "problem_class": "godot-save-checksum-canonicalization", "result": "passed", "tests": 19}