Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
98 commits
Select commit Hold shift + click to select a range
7448db9
Update requirements.txt
ikostan Apr 28, 2026
8fed052
Update README.md
ikostan Apr 28, 2026
4bb7121
Implement Encryption Key Management for Config Files #529
ikostan Apr 28, 2026
9f94f42
[FEATURE] Encrypt Game Settings Save/Load logic (globals.gd) #530
ikostan Apr 28, 2026
f9ad750
Update audio_manager.gd
ikostan Apr 28, 2026
019b4ad
[FEATURE] Encrypt Input Mappings Save/Load logic (settings.gd)
ikostan Apr 28, 2026
f33d2b1
Update globals.gd
ikostan Apr 28, 2026
52311cf
issue (bug_risk): Loading input mappings only via encrypted config ri…
ikostan Apr 28, 2026
31057fb
issue: Audio volume configs are now encrypted-only, which may orphan …
ikostan Apr 28, 2026
a07480c
Write the salt under the [game] section, not [application].
ikostan Apr 28, 2026
02b0f91
428-430: Avoid silent weak-key fallback in non-debug builds
ikostan Apr 28, 2026
176c97f
Update globals.gd
ikostan Apr 28, 2026
bba890d
Update globals.gd
ikostan Apr 28, 2026
b4839df
update the manual setup in test_settings.gd to save the mock files us…
ikostan Apr 28, 2026
9fac881
update the two failing test methods in test_audio_manager.gd
ikostan Apr 28, 2026
7340c2c
need to update the mock file creation and verification steps
ikostan Apr 28, 2026
9503b91
Update test_settings_persistence.gd
ikostan Apr 28, 2026
8940baf
Update test_sfx_weapon_volume_control.gd
ikostan Apr 28, 2026
3c4b502
Update test_settings.gd
ikostan Apr 29, 2026
89c78d4
Update test_settings.gd
ikostan Apr 29, 2026
3944a4a
Add a guard to prevent overwriting existing files when both loads fail.
ikostan Apr 29, 2026
101c108
make the system completely bulletproof against initialization race co…
ikostan Apr 29, 2026
99e6134
Update test_audio_reset_button.gd
ikostan Apr 30, 2026
0102407
Update test_basic_save_load_without_other_settings.gd
ikostan Apr 30, 2026
6521af5
Update test_blank_key_labels_on_missing_config.gd
ikostan Apr 30, 2026
82fbded
Update test_audio_sync_decoupling.gd
ikostan Apr 30, 2026
69b19f6
Update test_deduplication_on_load.gd
ikostan Apr 30, 2026
4c9b18d
Update test_combined_multi_manager_loads.gd
ikostan Apr 30, 2026
8ddbc8c
Update test_deduplication_on_migration.gd
ikostan Apr 30, 2026
857c788
Update test_deduplication_on_reset.gd
ikostan Apr 30, 2026
197aa46
Update test_deduplication_on_save_load_cycle.gd
ikostan Apr 30, 2026
837e735
Update test_error_edge_cases.gd
ikostan Apr 30, 2026
d346639
Update test_fuel_integration.gd
ikostan Apr 30, 2026
50a526e
Update test_fuel_persistence_integration.gd
ikostan Apr 30, 2026
fe8d865
Update test_globals_resource.gd
ikostan Apr 30, 2026
9828191
fixing helper method
ikostan Apr 30, 2026
6795717
Update globals.gd
ikostan Apr 30, 2026
199a217
Update gut_test_helper.gd
ikostan Apr 30, 2026
06f8dc3
Update test_integration_key_mapping.gd
ikostan Apr 30, 2026
ec4db4f
Update test_manual_duplicate_load.gd
ikostan Apr 30, 2026
b7ffb32
Update test_master_volume_control_and_music.gd
ikostan Apr 30, 2026
80d9303
Update test_preserve_other_sections.gd
ikostan Apr 30, 2026
4317f75
Update test_master_volume_control_and_music.gd
ikostan Apr 30, 2026
af57dea
Update test_reset_scenarios.gd
ikostan Apr 30, 2026
1773e39
Update test_settings_ec.gd
ikostan Apr 30, 2026
7a99fe6
Update test_settings_observer.gd
ikostan Apr 30, 2026
db4994a
Update test_settings_unbound_scenarios.gd
ikostan Apr 30, 2026
ef5dc4c
Update test_audio_web_bridge.gd
ikostan Apr 30, 2026
d51076e
Update test/gut/test_deduplication_on_migration.gd
ikostan Apr 30, 2026
50d069b
Merge branch 'implement-encryption-key-management-for-config-files' o…
ikostan Apr 30, 2026
c3858f9
Update test/gut/test_manual_duplicate_load.gd
ikostan Apr 30, 2026
27e775a
Merge branch 'implement-encryption-key-management-for-config-files' o…
ikostan Apr 30, 2026
4200666
Update test_settings_unbound_scenarios.gd
ikostan Apr 30, 2026
9b7d09b
Update test_settings_unbound_scenarios.gd
ikostan Apr 30, 2026
6336864
Update test_master_volume_control_and_music.gd
ikostan Apr 30, 2026
2d287bb
Update test_sfx_volume_control.gd
ikostan Apr 30, 2026
a823170
Update test_sfx_rotor_volume_control.gd
ikostan Apr 30, 2026
5435640
Update test_sfx_weapon_volume_control.gd
ikostan Apr 30, 2026
9cbb838
Update settings.gd
ikostan Apr 30, 2026
1e87e57
Update audio_manager.gd
ikostan Apr 30, 2026
5e94cd9
Add GUT unit tests that specifically cover migration from plaintext t…
ikostan Apr 30, 2026
7c056f6
Update test_settings_migration.gd
ikostan Apr 30, 2026
3d5af68
Update settings.gd
ikostan Apr 30, 2026
e94b916
Update audio_manager.gd
ikostan Apr 30, 2026
980610f
Update test_sfx_rotor_volume_control.gd
ikostan Apr 30, 2026
0be7c9b
Update test_sfx_volume_control.gd
ikostan Apr 30, 2026
5893906
Update difficulty_flow_test.py
ikostan Apr 30, 2026
877f071
style: format code with Black and isort
deepsource-autofix[bot] Apr 30, 2026
e309246
🚨 issue (security): Returning an empty string as a "hard fail" key ma…
ikostan Apr 30, 2026
f4e9bab
suggestion (bug_risk): The salt injection step is tightly coupled to …
ikostan Apr 30, 2026
e2eec15
Classic "DRY" (Don't Repeat Yourself) refactoring opportunity.
ikostan Apr 30, 2026
2d03d58
Update globals.gd
ikostan Apr 30, 2026
c7b76a2
Update audio_manager.gd
ikostan Apr 30, 2026
d4f427b
Refactoring
ikostan Apr 30, 2026
0789bd5
Update settings.gd
ikostan Apr 30, 2026
e31d692
Update test_error_edge_cases.gd
ikostan Apr 30, 2026
721e7d5
Update test_settings_ec.gd
ikostan Apr 30, 2026
59f891b
Update test_settings.gd
ikostan Apr 30, 2026
3a6074d
Update globals.gd
ikostan Apr 30, 2026
fd0eeb2
Fixing Playwright tests
ikostan May 1, 2026
e5c2b7f
Update globals.gd
ikostan May 1, 2026
0ea599b
Still working on playwright tsts
ikostan May 1, 2026
bf0b27e
ditch the feature flags entirely and make your game self-aware
ikostan May 1, 2026
a988c35
style: format code with Black and isort
deepsource-autofix[bot] May 1, 2026
b7ab971
Update globals.gd
ikostan May 1, 2026
b8464dc
Update settings.gd
ikostan May 1, 2026
a8f939b
issue (bug_risk): Guard against failed config loads before overwritin…
ikostan May 1, 2026
509fb31
Update settings.gd
ikostan May 1, 2026
87cff8b
Update test_settings_migration.gd
ikostan May 1, 2026
8687b8f
suggestion: Consider centralizing these hard-coded timeouts so browse…
ikostan May 1, 2026
ca7c5b4
style: format code with Black and isort
deepsource-autofix[bot] May 1, 2026
ce74c3e
Update audio_flow_test.py
ikostan May 1, 2026
d6bb969
suggestion: Consider centralizing these hard-coded timeouts so browse…
ikostan May 1, 2026
2ba79fd
Merge branch 'implement-encryption-key-management-for-config-files' o…
ikostan May 1, 2026
8f43171
style: format code with Black and isort
deepsource-autofix[bot] May 1, 2026
ab3f338
suggestion (testing): This assertion is tightly coupled to a specific…
ikostan May 2, 2026
8cffe3b
suggestion (bug_risk): Escape or constrain the salt value to avoid br…
ikostan May 2, 2026
49170dc
Update deploy_to_itch.yml
ikostan May 2, 2026
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
2 changes: 1 addition & 1 deletion .github/workflows/browser_test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
description: "Playwright timeout in ms"
required: false
type: "number"
default: 10000
default: 30000

jobs:
test:
Expand All @@ -30,18 +30,18 @@
id: "export"
uses: "firebelley/godot-export@930577654862a320eef793f399ee911b4479efb9"
with:
godot_executable_download_url: "https://github.com/godotengine/godot/releases/download/4.5-stable/Godot_v4.5-stable_linux.x86_64.zip"

Check warning on line 33 in .github/workflows/browser_test.yml

View workflow job for this annotation

GitHub Actions / YAML Lint / build (3.x)

33:91 [line-length] line too long (143 > 90 characters)
godot_export_templates_download_url: "https://github.com/godotengine/godot/releases/download/4.5-stable/Godot_v4.5-stable_export_templates.tpz"

Check warning on line 34 in .github/workflows/browser_test.yml

View workflow job for this annotation

GitHub Actions / YAML Lint / build (3.x)

34:91 [line-length] line too long (153 > 90 characters)
relative_project_path: "./"
relative_export_path: "./export/web_thread_off"
archive_output: false
cache: false
verbose: true
presets_to_export: "Web_thread_off"
use_preset_export_path: true # Move exports to the directory defined in export_presets.cfg

Check warning on line 41 in .github/workflows/browser_test.yml

View workflow job for this annotation

GitHub Actions / YAML Lint / build (3.x)

41:91 [line-length] line too long (101 > 90 characters)
- name: "Flatten Export Directory"
run: |
bash ./.github/scripts/flatten_export.sh "export/web_thread_off" "Web_thread_off"

Check warning on line 44 in .github/workflows/browser_test.yml

View workflow job for this annotation

GitHub Actions / YAML Lint / build (3.x)

44:91 [line-length] line too long (91 > 90 characters)
- name: "List Export Directory Contents"
run: |
ls -la export/web_thread_off
Expand All @@ -54,11 +54,11 @@
python-version: "3.12"
- name: "Cache PIP Dependencies"
uses: "actions/cache@v5"
id: cache

Check warning on line 57 in .github/workflows/browser_test.yml

View workflow job for this annotation

GitHub Actions / YAML Lint / build (3.x)

57:13 [quoted-strings] string value is not quoted with double quotes
with:
path: "~/.cache/pip"
key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}

Check warning on line 60 in .github/workflows/browser_test.yml

View workflow job for this annotation

GitHub Actions / YAML Lint / build (3.x)

60:16 [quoted-strings] string value is not quoted with double quotes
restore-keys: ${{ runner.os }}-pip-

Check warning on line 61 in .github/workflows/browser_test.yml

View workflow job for this annotation

GitHub Actions / YAML Lint / build (3.x)

61:25 [quoted-strings] string value is not quoted with double quotes
- name: "Install Playwright"
run: |
sudo apt-get update && sudo apt-get install -y libxml2-utils
Expand All @@ -66,8 +66,8 @@
- name: "Cache Playwright Browsers"
uses: "actions/cache@v5"
with:
path: ~/.cache/ms-playwright

Check warning on line 69 in .github/workflows/browser_test.yml

View workflow job for this annotation

GitHub Actions / YAML Lint / build (3.x)

69:17 [quoted-strings] string value is not quoted with double quotes
key: ${{ runner.os }}-playwright-${{ hashFiles('**/requirements.txt') }} # Or your dependency file

Check warning on line 70 in .github/workflows/browser_test.yml

View workflow job for this annotation

GitHub Actions / YAML Lint / build (3.x)

70:91 [line-length] line too long (109 > 90 characters)

Check warning on line 70 in .github/workflows/browser_test.yml

View workflow job for this annotation

GitHub Actions / YAML Lint / build (3.x)

70:16 [quoted-strings] string value is not quoted with double quotes
restore-keys: ${{ runner.os }}-playwright-

- name: "Install Playwright Browsers"
Expand Down
69 changes: 69 additions & 0 deletions .github/workflows/deploy_to_itch.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ on: # yamllint disable-line rule:truthy
ITCHIO_API_KEY:
description: "Itch.io API key"
required: true
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
PRODUCTION_SALT:
description: "production salt secret key"
required: true

jobs:
export-and-deploy:
Expand All @@ -38,6 +41,72 @@ jobs:
else
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
printf '\n[application]\nconfig/version="%s"\n' "$ESCAPED_VERSION" >> project.godot
fi
- name: "Inject Production Salt into project.godot"
run: |
RAW_SALT="${{ secrets.PRODUCTION_SALT }}"
# This line safely escapes any quotes or slashes so your config doesn't break!
SALT=$(printf '%s' "$RAW_SALT" | sed 's/\\/\\\\/g; s/"/\\"/g')
# Use a section-aware awk script to update security/save_salt only within the [game] section.
# Behavior:
# - If [game] exists and security/save_salt is present within it, replace that line.
# - If [game] exists but security/save_salt is missing, append it at the end of the [game] section.
# - If [game] does not exist, append a new [game] section with security/save_salt at the end of the file.
awk '
BEGIN {
salt = ENVIRON["SALT"]
in_game = 0
salt_written = 0
saw_game_section = 0
}
{
# Detect section headers
if ($0 ~ /^\[game\]/) {
in_game = 1
saw_game_section = 1
print
next
} else if ($0 ~ /^\[/ && $0 !~ /^\[game\]/) {
# We are entering another section; if we were in [game] and havent written the salt yet, write it now
if (in_game && !salt_written) {
print "security/save_salt=\"" salt "\""
salt_written = 1
}
in_game = 0
print
next
}

# Inside [game], look for security/save_salt key (allow leading whitespace)
if (in_game && $0 ~ /^[[:space:]]*security\/save_salt[[:space:]]*=/) {
if (!salt_written) {
print "security/save_salt=\"" salt "\""
salt_written = 1
}
# Skip the original line
next
}

print
}
END {
# If we were still inside [game] at EOF and haven t written the salt, append it
if (in_game && !salt_written) {
print "security/save_salt=\"" salt "\""
salt_written = 1
}

# If no [game] section existed at all, create it with the salt
if (!saw_game_section) {
if (NR > 0) {
print ""
}
print "[game]"
print "security/save_salt=\"" salt "\""
}
}
' project.godot > project.godot.tmp

mv project.godot.tmp project.godot
- name: "Create Export Directories"
run: |
mkdir -p export/web
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/lint_test_deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,4 @@ jobs:
version: ${{ needs.release_drafter.outputs.release_tag }}
secrets:
ITCHIO_API_KEY: "${{ secrets.ITCHIO_API_KEY }}"
PRODUCTION_SALT: "${{ secrets.PRODUCTION_SALT }}"
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
![Repo Size](https://img.shields.io/github/repo-size/ikostan/SkyLockAssault?style=flat-square)
![Closed Issues](https://img.shields.io/github/issues-closed/ikostan/SkyLockAssault?style=flat-square&label=Issues&color=green)
![Open Issues](https://img.shields.io/github/issues/ikostan/SkyLockAssault?style=flat-square&label=Issues&color=red)
[![Known Vulnerabilities](https://snyk.io/test/github/ikostan/SkyLockAssault/badge.svg)](https://snyk.io/test/github/ikostan/SkyLockAssault)
[![All Contributors](https://img.shields.io/github/all-contributors/ikostan/SkyLockAssault?color=ee8449&style=flat-square)](#contributors)

## A top-down online web browser game built with Godot 4.5
Expand Down
Binary file modified requirements.txt
Binary file not shown.
1 change: 1 addition & 0 deletions run_browser_tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ echo "Exporting Godot Project to Web..."
mkdir -p $EXPORT_DIR

# Simulate firebelley/godot-export action: Run Godot export to HTML5
# godot --headless --path $PROJECT_DIR --export-release "Web_thread_off" $EXPORT_DIR/index.html
godot --headless --path $PROJECT_DIR --export-release "Web_thread_off" $EXPORT_DIR/index.html
check_exit "Godot Web Export"

Expand Down
193 changes: 141 additions & 52 deletions scripts/core/globals.gd
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@ enum LogLevel { DEBUG, INFO, WARNING, ERROR, NONE = 4 }
## Path to the navigation sound file
const UI_NAV_SOUND_PATH: String = "res://files/sounds/sfx/ui_navigation.wav"

# --- TASK #529: Encryption Key Management ---
## Centralized key for securing local configuration files.
## This ensures consistent encryption/decryption across different game systems. [cite: 3]
## Define the variable by pulling from ProjectSettings.
## If the setting doesn't exist, it falls back to a non-secure string.
var save_encryption_pass: String = _get_encryption_key()

# Add the resource reference here
var settings: GameSettingsResource
# In globals.gd (add after @export vars)
Expand Down Expand Up @@ -68,6 +75,10 @@ func _ready() -> void:
if settings:
settings.setting_changed.connect(_on_setting_changed)

# NEW: Signal Playwright that the engine is ready
if OS.has_feature("web"):
JavaScriptBridge.eval("window.godotInitialized = true")


## Reactive handler for the Observer Pattern
func _on_setting_changed(setting_name: String, new_value: Variant) -> void:
Expand Down Expand Up @@ -158,100 +169,85 @@ func load_key_mapping(menu_to_hide: Node) -> void:
get_tree().root.add_child(km_instance)


## Loads persisted settings from config if valid types;
## skips invalid/missing to keep current.
## Loads persisted settings with backward compatibility for plaintext files.
## :param path: Config file path (default: Settings.CONFIG_PATH).
## skips invalid/missing to keep current.
## :type path: String
## :rtype: void
func _load_settings(path: String = Settings.CONFIG_PATH) -> void:
var config: ConfigFile = ConfigFile.new()
var err: int = config.load(path)
var load_data: Dictionary = safe_load_config(path)
var config: ConfigFile = load_data["config"]
var err: int = load_data["err"]
var needs_migration: bool = load_data["is_legacy"]

if needs_migration:
log_message("Legacy plaintext settings found. Migration required.", LogLevel.INFO)

if err == OK:
# Enable the guard before starting bulk updates
_is_loading_settings = true

if config.has_section_key("Settings", "log_level"):
var loaded_log_level: Variant = config.get_value("Settings", "log_level")
if (
loaded_log_level is int
and loaded_log_level >= LogLevel.DEBUG
and loaded_log_level <= LogLevel.NONE
):
if loaded_log_level is int and loaded_log_level >= 0 and loaded_log_level <= 4:
settings.current_log_level = loaded_log_level
log_message(
"Loaded saved log level: " + LogLevel.keys()[settings.current_log_level],
LogLevel.DEBUG
)
else:
log_message(
"Invalid type or value for log_level: " + str(typeof(loaded_log_level)),
LogLevel.WARNING
)

if config.has_section_key("Settings", "difficulty"):
var loaded_difficulty: Variant = config.get_value("Settings", "difficulty")
if (loaded_difficulty is float) or (loaded_difficulty is int):
# Validate and clamp difficulty to slider range (0.5-2.0)
settings.difficulty = loaded_difficulty
log_message("Loaded saved difficulty: " + str(settings.difficulty), LogLevel.DEBUG)
else:
log_message(
"Invalid type for difficulty: " + str(typeof(loaded_difficulty)),
LogLevel.WARNING
)

# NEW: Load the debug logging flag
if config.has_section_key("Settings", "enable_debug_logging"):
var loaded_debug: Variant = config.get_value("Settings", "enable_debug_logging")
if loaded_debug is bool:
settings.enable_debug_logging = loaded_debug
log_message(
"Loaded saved debug logging: " + str(settings.enable_debug_logging),
LogLevel.DEBUG
)

# NEW: Load the fuel related settings
if config.has_section_key("Settings", "max_fuel"):
var loaded_max: Variant = config.get_value("Settings", "max_fuel")
if loaded_max is float or loaded_max is int:
settings.max_fuel = float(loaded_max)
else:
log_message(
"Invalid type for max_fuel: " + str(typeof(loaded_max)), LogLevel.WARNING
)

# Disable the guard and log a single summary instead
_is_loading_settings = false
log_message("All settings loaded and synchronized.", LogLevel.DEBUG)
log_message("Settings synchronization complete.", LogLevel.DEBUG)

if needs_migration:
log_message("Upgrading settings file to encrypted format...", LogLevel.INFO)
_save_settings(path)

elif err == ERR_FILE_NOT_FOUND:
log_message("No settings config found, using defaults.", LogLevel.DEBUG)
log_message("No configuration file found; using defaults.", LogLevel.DEBUG)
else:
log_message("Failed to load settings config: " + str(err), LogLevel.ERROR)
log_message("Failed to load settings (Error %d)." % err, LogLevel.ERROR)


# New: Add _save_settings to globals.gd (move from options_menu.gd if needed)
## New: Add _save_settings to globals.gd (move from options_menu.gd if needed)
## Persists current settings to an encrypted config file.
## :param path: Config file path (default: Settings.CONFIG_PATH).
func _save_settings(path: String = Settings.CONFIG_PATH) -> void:
var config: ConfigFile = ConfigFile.new()
var err: int = config.load(path) # Load existing to preserve other sections
var load_data: Dictionary = safe_load_config(path)
var config: ConfigFile = load_data["config"]
var err: int = load_data["err"]

if err != OK and err != ERR_FILE_NOT_FOUND:
log_message(
"Failed to load settings from " + path + " for save: " + str(err), LogLevel.ERROR
(
"CRITICAL: Could not load settings from "
+ path
+ ", aborting save to prevent data loss."
),
LogLevel.ERROR
)
return

config.set_value("Settings", "log_level", settings.current_log_level)
config.set_value("Settings", "difficulty", settings.difficulty)
# NEW: Persist the debug logging flag
config.set_value("Settings", "enable_debug_logging", settings.enable_debug_logging)
# NEW: Persist the fuel settings
config.set_value("Settings", "max_fuel", settings.max_fuel)

err = config.save(path)
# Always save using encryption from this point forward
# FIX: Use the centralized key ensurer
err = config.save_encrypted_pass(path, ensure_encryption_key())

if err != OK:
log_message("Failed to save settings: " + str(err), LogLevel.ERROR)
log_message("CRITICAL: Failed to save encrypted settings: " + str(err), LogLevel.ERROR)
else:
log_message("Settings saved.", LogLevel.DEBUG)
log_message("Encrypted settings persisted successfully.", LogLevel.DEBUG)
Comment thread
coderabbitai[bot] marked this conversation as resolved.


func _on_options_exited_unexpectedly() -> void:
Expand Down Expand Up @@ -410,3 +406,96 @@ func _play_ui_navigation_sfx() -> void:
# If the sound is already playing (e.g., from rapid button presses),
# restart it from the beginning to feel responsive.
_nav_sfx_player.play()


## Ensures the encryption key is initialized and returns it.
## Centralizes the safety check so other scripts don't have to repeat it.
func ensure_encryption_key() -> String:
if save_encryption_pass.is_empty():
save_encryption_pass = _get_encryption_key()
return save_encryption_pass


## Generates a unique, deterministic encryption key for local save files.
##
## This function combines the device's hardware ID (`OS.get_unique_id()`) with a
## project-specific salt retrieved from `ProjectSettings`, returning a SHA-256 hash.
##
## Security Guard:
## In production builds (when neither 'editor' nor 'debug' features are present),
## this function strictly validates that a secure salt was successfully injected
## during the CI/CD deployment. If the salt is missing or matches the weak development
## fallback, it forces an immediate engine crash. This prevents the game from silently
## encrypting data with a weak/empty key.
##
## :rtype: String (The SHA-256 hashed key)
## Generates a unique, deterministic encryption key for local save files.
## Generates a unique, deterministic encryption key for local save files.
func _get_encryption_key() -> String:
var salt: String = ProjectSettings.get_setting("game/security/save_salt", "dev_fallback_salt")

# NEW: Make the game self-aware of Playwright/Puppeteer testing!
var is_automated_test: bool = false
if OS.has_feature("web"):
is_automated_test = JavaScriptBridge.eval("navigator.webdriver") == true

# SECURITY GUARD: Prevent silent weak-key fallback in production.
# We now allow the dev salt if the browser is driven by automated tests.
if not OS.has_feature("editor") and not OS.has_feature("debug") and not is_automated_test:
if salt == "dev_fallback_salt" or salt.is_empty():
var error_msg: String = "CRITICAL SECURITY ERROR: Missing salt."
push_error(error_msg)
OS.crash(error_msg)
return ""

# FIX: OS.get_unique_id() crashes on Web
var device_id: String = "web_fallback"
if OS.get_name() != "Web":
device_id = OS.get_unique_id()

return (device_id + salt).sha256_text()


## Helper to determine if a config file is encrypted.
func is_file_encrypted(path: String) -> bool:
if not FileAccess.file_exists(path):
return false
var f: FileAccess = FileAccess.open(path, FileAccess.READ)
if not f:
return false
if f.get_length() < 4:
f.close()
return false
var magic: int = f.get_32()
f.close()
# Godot Encrypted File Magic Number: 0x43454447 ("GDEC")
return magic == 0x43454447


## Safely loads a config file, handling both encrypted and legacy plaintext formats.
## Returns a Dictionary: {"config": ConfigFile, "err": int, "is_legacy": bool}
func safe_load_config(path: String) -> Dictionary:
# FIX: Delegate to centralized helper
var key: String = ensure_encryption_key()

var config: ConfigFile = ConfigFile.new()
var err: int = OK
var is_legacy: bool = false

if not FileAccess.file_exists(path):
err = ERR_FILE_NOT_FOUND
elif is_file_encrypted(path):
err = config.load_encrypted_pass(path, key)
else:
err = config.load(path)
if err == OK:
is_legacy = true

return {"config": config, "err": err, "is_legacy": is_legacy}


## Overrides the encryption key with a deterministic value for unit tests.
## This decouples test artifacts from specific hardware IDs so failures are reproducible.
func set_test_encryption_key(override_key: String = "test_deterministic_key_123") -> void:
save_encryption_pass = override_key
log_message("Encryption key overridden for testing.", LogLevel.DEBUG)
Loading
Loading