Implement encryption key management for config files#588
Implement encryption key management for config files#588
Conversation
Implement Task #529: Encryption Key ManagementThis commit introduces a centralized security layer for local configuration files.Changes: * globals.gd: Added save_encryption_pass which fetches a salt from ProjectSettings and combines it with the hardware ID for a unique, deterministic key. * Persistence: Refactored _load_settings and _save_settings to utilize load_encrypted_pass and save_encrypted_pass respectively. CI/CD: Split the version injection into two steps in deploy_to_itch.yml and added a step to inject PRODUCTION_SALT from GitHub Secrets. * Style: Fixed naming conventions to satisfy gdlint class-variable-name requirements.
Reviewer's GuideIntroduce centralized encryption key management and migrate all config persistence (settings, input mappings, audio) to encrypted files with backward-compatible loading, CI-injected production salt, and extensive test updates for encrypted I/O and migration flows. Sequence diagram for loading and migrating settings from plaintext to encryptedsequenceDiagram
actor Player
participant Game as GameStartup
participant Globals as Globals
participant Config as ConfigFile
participant FS as FileAccess
Player->>Game: Start game
Game->>Globals: _load_settings(path)
Globals->>Globals: safe_load_config(path)
alt file_missing
FS->>Globals: file_exists(path) = false
Globals-->>Globals: return {config, ERR_FILE_NOT_FOUND, is_legacy false}
Globals-->>Game: err = ERR_FILE_NOT_FOUND
Game-->>Player: Use default settings
else file_exists
FS->>Globals: file_exists(path) = true
Globals->>Globals: is_file_encrypted(path)
alt encrypted_file
Globals->>Config: load_encrypted_pass(path, ensure_encryption_key())
Config-->>Globals: err = OK, is_legacy = false
else legacy_plaintext
Globals->>Config: load(path)
Config-->>Globals: err = OK, is_legacy = true
end
Globals-->>Game: {config, err, is_legacy}
alt err == OK
Game->>Game: apply settings from config
alt is_legacy == true
Game->>Globals: log_message(Migration required)
Game->>Globals: _save_settings(path)
Globals->>Globals: safe_load_config(path)
Globals->>Config: set_value(Settings, ...)
Globals->>Config: save_encrypted_pass(path, ensure_encryption_key())
Config-->>Globals: err = OK
else already_encrypted
Game->>Globals: log_message(Settings synchronized)
end
else err != OK and err != ERR_FILE_NOT_FOUND
Game->>Globals: log_message(load error)
Game-->>Player: Continue with in-memory defaults
end
end
Sequence diagram for saving encrypted input mappings and audio settingssequenceDiagram
actor Player
participant Settings as SettingsScript
participant Audio as AudioManager
participant Globals as Globals
participant Config as ConfigFile
Player->>Settings: Change key bindings
Settings->>Settings: InputMap updated
Player->>Audio: Adjust volumes
Audio->>Audio: Bus states updated
rect rgb(230,230,255)
Settings->>Settings: save_input_mappings(path)
Settings->>Globals: safe_load_config(path)
Globals-->>Settings: {config, err, is_legacy}
alt err == OK or ERR_FILE_NOT_FOUND
Settings->>Config: set_value(input, action, serials)
Settings->>Globals: ensure_encryption_key()
Globals-->>Settings: encryption_key
Settings->>Config: save_encrypted_pass(path, encryption_key)
Config-->>Settings: err = OK
else load_error
Settings->>Globals: log_message(load error)
end
end
rect rgb(230,255,230)
Audio->>Audio: save_volumes(path)
Audio->>Globals: safe_load_config(path)
Globals-->>Audio: {config, err, is_legacy}
alt err == OK or ERR_FILE_NOT_FOUND
Audio->>Config: set_value(audio, volume_var, volume)
Audio->>Config: set_value(audio, muted_var, muted)
Audio->>Globals: ensure_encryption_key()
Globals-->>Audio: encryption_key
Audio->>Config: save_encrypted_pass(path, encryption_key)
Config-->>Audio: err = OK
else load_error
Audio->>Globals: log_message(load error)
end
end
Class diagram for encrypted config management and key handlingclassDiagram
class Globals {
<<node>>
+LogLevel enum
+save_encryption_pass String
+settings GameSettingsResource
+_ready() void
+_load_settings(path String) void
+_save_settings(path String) void
+ensure_encryption_key() String
+_get_encryption_key() String
+is_file_encrypted(path String) bool
+safe_load_config(path String) Dictionary
+set_test_encryption_key(override_key String) void
}
class SettingsScript {
<<script>>
+CONFIG_PATH String
+ACTIONS Array~String~
+LEGACY_MIGRATION_KEY String
+_needs_save bool
+load_input_mappings(path String, actions Array~String~) void
+save_input_mappings(path String, actions Array~String~) void
+save_last_input_device(device String) void
+load_last_input_device() void
+_is_file_encrypted(path String) bool
}
class AudioManager {
<<script>>
+current_config_path String
+load_volumes(path String) void
+save_volumes(path String) void
+set_bus_state(bus String, volume float, muted bool) void
+get_bus_state(bus String) Dictionary
}
class ConfigFile {
+load(path String) int
+save(path String) int
+load_encrypted_pass(path String, key String) int
+save_encrypted_pass(path String, key String) int
+has_section_key(section String, key String) bool
+get_value(section String, key String, default Variant) Variant
+set_value(section String, key String, value Variant) void
}
class FileAccess {
+file_exists(path String) bool
+open(path String, mode int) FileAccess
+get_length() int
+get_32() int
+close() void
}
class ProjectSettings {
+get_setting(key String, default Variant) Variant
}
class OS {
+has_feature(feature String) bool
+get_name() String
+get_unique_id() String
+crash(message String) void
}
class JavaScriptBridge {
+eval(script String) Variant
}
%% Relationships
Globals --> ConfigFile : uses
Globals --> FileAccess : uses
Globals --> ProjectSettings : reads_save_salt
Globals --> OS : checks_environment
Globals --> JavaScriptBridge : detects_webdriver
SettingsScript --> Globals : uses_safe_load_config
SettingsScript --> Globals : uses_ensure_encryption_key
SettingsScript --> ConfigFile : operates_on_configs
AudioManager --> Globals : uses_safe_load_config
AudioManager --> Globals : uses_ensure_encryption_key
AudioManager --> ConfigFile : operates_on_configs
SettingsScript --> FileAccess : legacy_is_file_encrypted
File-Level Changes
Assessment against linked issues
Possibly linked issues
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds encrypted ConfigFile persistence with derived encryption key tied to ProjectSettings salt and device ID, updates runtime to prefer encrypted load/save with plaintext-fallback migration, injects Changes
Sequence Diagram(s)sequenceDiagram
participant CI as GitHub Actions (deploy)
participant Repo as Repository (project.godot)
participant Game as Game runtime (Globals)
participant Device as DeviceID provider
participant Config as ConfigFile API
CI->>Repo: inject `PRODUCTION_SALT` into `game/security/save_salt`
Repo->>Game: runtime starts, reads ProjectSettings (including save_salt)
Game->>Device: query unique device id
Game->>Game: _get_encryption_key() := SHA256(salt + device_id)
Game->>Config: load_encrypted_pass(path, encryption_key)
alt encrypted load OK
Config-->>Game: return config
else ERR_INVALID_DATA / ERR_FILE_CORRUPT
Game->>Config: attempt legacy plaintext load(path)
alt legacy load OK
Config-->>Game: return legacy config
Game->>Config: save_encrypted_pass(path, encryption_key) -- migrate to encrypted
else
Config-->>Game: return defaults / error (abort save to avoid overwriting)
end
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related issues
Possibly related PRs
Suggested labels
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
|
Overall Grade |
Security Reliability Complexity Hygiene |
Code Review Summary
| Analyzer | Status | Updated (UTC) | Details |
|---|---|---|---|
| Python | May 2, 2026 4:23a.m. | Review ↗ | |
| JavaScript | May 2, 2026 4:23a.m. | Review ↗ |
Important
AI Review is run only on demand for your team. We're only showing results of static analysis review right now. To trigger AI Review, comment @deepsourcebot review on this thread.
There was a problem hiding this comment.
Hey - I've found 3 issues, and left some high level feedback:
- The migration logic that falls back to plaintext and re-saves encrypted is implemented only for
_load_settings/_save_settings; consider applying the same dual-load approach to input mappings and audio configs so existing plaintext config files are seamlessly migrated instead of just failing encrypted loads. - In places that rely on
Globals.save_encryption_pass(e.g.,settings.gdandaudio_manager.gd), you assume the key is non-empty; for robustness you might mirror the_load_settingssafeguard by re-deriving the key if it ends up empty or uninitialized at call time.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- The migration logic that falls back to plaintext and re-saves encrypted is implemented only for `_load_settings`/`_save_settings`; consider applying the same dual-load approach to input mappings and audio configs so existing plaintext config files are seamlessly migrated instead of just failing encrypted loads.
- In places that rely on `Globals.save_encryption_pass` (e.g., `settings.gd` and `audio_manager.gd`), you assume the key is non-empty; for robustness you might mirror the `_load_settings` safeguard by re-deriving the key if it ends up empty or uninitialized at call time.
## Individual Comments
### Comment 1
<location path="scripts/core/settings.gd" line_range="256-257" />
<code_context>
var config: ConfigFile = ConfigFile.new()
- var err: int = config.load(path)
+
+ # Use encrypted load
+ var err: int = config.load_encrypted_pass(path, Globals.save_encryption_pass)
+
if err != OK and err != ERR_FILE_NOT_FOUND: # Handle errors except missing file
</code_context>
<issue_to_address>
**issue (bug_risk):** Loading input mappings only via encrypted config risks breaking backward compatibility with existing plaintext configs.
In `globals.gd`, `_load_settings` already handles migration by trying encrypted first, then falling back to plaintext on `ERR_INVALID_DATA`/`ERR_FILE_CORRUPT`, and re-saving encrypted.
`load_input_mappings` now only calls `load_encrypted_pass` and treats any non-OK (except `ERR_FILE_NOT_FOUND`) as fatal. Existing plaintext input configs will likely return `ERR_INVALID_DATA`, causing all mappings to fail to load and skipping migration.
To avoid this regression, mirror the `_load_settings` flow here:
- Call `load_encrypted_pass` first.
- On `ERR_INVALID_DATA`/`ERR_FILE_CORRUPT`, reset the `ConfigFile` and call `load(path)`.
- If that succeeds, flag the config for migration and re-save it encrypted in `save_input_mappings`.
</issue_to_address>
### Comment 2
<location path="scripts/managers/audio_manager.gd" line_range="244-247" />
<code_context>
- var err: int = config.load(path)
+
+ # Use encrypted load
+ var err: int = config.load_encrypted_pass(path, Globals.save_encryption_pass)
+
if err != OK and err != ERR_FILE_NOT_FOUND: # Handle errors except missing file
</code_context>
<issue_to_address>
**issue:** Audio volume configs are now encrypted-only, which may orphan existing plaintext configs.
`load_volumes`/`save_volumes` now assume the file is encrypted. On systems with an existing plaintext audio config, `load_encrypted_pass` will likely return `ERR_INVALID_DATA`/`ERR_FILE_CORRUPT`, so volumes are skipped with no migration and users effectively lose their saved settings.
To prevent that, consider:
- Using the same approach as `settings.gd`: try encrypted first, then fall back to plaintext on specific error codes.
- When plaintext load succeeds, immediately re-save using `save_encrypted_pass` to migrate the file.
This preserves existing volume settings while adopting encryption.
</issue_to_address>
### Comment 3
<location path=".github/workflows/deploy_to_itch.yml" line_range="16-15" />
<code_context>
ITCHIO_API_KEY:
description: "Itch.io API key"
required: true
+ PRODUCTION_SALT:
+ description: "production salt secret key"
+ required: true
jobs:
</code_context>
<issue_to_address>
**suggestion:** PRODUCTION_SALT is defined as a workflow input but the step reads it from secrets instead.
The `workflow_dispatch` input `PRODUCTION_SALT` is declared but not used; the step "Inject Production Salt into project.godot" references `${{ secrets.PRODUCTION_SALT }}` instead of `${{ inputs.PRODUCTION_SALT }}`. Either remove the unused input if you intend to rely solely on the secret, or update the step to use `${{ inputs.PRODUCTION_SALT }}` and document which source is authoritative.
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
…sks breaking backward compatibility with existing plaintext configs. issue (bug_risk): Loading input mappings only via encrypted config risks breaking backward compatibility with existing plaintext configs. In globals.gd, _load_settings already handles migration by trying encrypted first, then falling back to plaintext on ERR_INVALID_DATA/ERR_FILE_CORRUPT, and re-saving encrypted. load_input_mappings now only calls load_encrypted_pass and treats any non-OK (except ERR_FILE_NOT_FOUND) as fatal. Existing plaintext input configs will likely return ERR_INVALID_DATA, causing all mappings to fail to load and skipping migration. To avoid this regression, mirror the _load_settings flow here: Call load_encrypted_pass first. On ERR_INVALID_DATA/ERR_FILE_CORRUPT, reset the ConfigFile and call load(path). If that succeeds, flag the config for migration and re-save it encrypted in save_input_mappings.
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (1)
scripts/core/globals.gd (1)
428-430: Avoid silent weak-key fallback in non-debug buildsAt Line 428, falling back to
"dev_fallback_salt"silently weakens the encryption guarantee if secret injection fails. Recommend failing fast (or at least loggingERRORand refusing encrypted persistence) in production builds.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@scripts/core/globals.gd` around lines 428 - 430, The current salt retrieval in the function that returns the hashed key uses ProjectSettings.get_setting("game/security/save_salt", "dev_fallback_salt") and silently falls back to the weak "dev_fallback_salt"; change this so production builds do not accept the fallback: detect when the setting is missing or equals the fallback and in non-debug (release) mode either log an ERROR via ProjectSettings/OS logger and abort/refuse encrypted persistence or raise/return a failure instead of using the weak salt; keep the current behavior only in debug/dev builds (using Engine.is_editor_hint() or OS.has_feature("debug") as appropriate) and reference the salt variable, ProjectSettings.get_setting call, and the function that returns (OS.get_unique_id() + salt).sha256_text() to locate where to implement the check and error handling.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In @.github/workflows/deploy_to_itch.yml:
- Around line 44-56: The workflow injects the salt under [application] but Godot
expects the key under the [game] section
(ProjectSettings.get_setting("game/security/save_salt", ...)); change the script
so it checks/updates the key prefix '^game/security/save_salt=' and, if missing,
appends the line under the '[game]' section (or creates a new '[game]' section
in the fallback) instead of using '[application]'. Ensure the inserted key name
is security/save_salt (i.e. game/security/save_salt) so the runtime lookup in
scripts/core/globals.gd picks up the injected salt.
In `@scripts/core/globals.gd`:
- Around line 248-252: The code currently falls back from
config.load_encrypted_pass to config.load but proceeds to save regardless,
risking overwriting unrelated sections; modify the logic around
config.load_encrypted_pass(...) and config.load(...) (variables err and
save_encryption_pass) so that after the plaintext fallback you check if err ==
OK before proceeding to any save/write operations (skip or abort save if err
remains an error), and add a clear log/error return path when both loads fail to
avoid destructive overwrites.
- Around line 175-199: The file fails the formatter check; run the Godot
formatter and commit the changes: format scripts/core/globals.gd with gdformat
(run gdformat --write ./scripts/core/globals.gd) so the block around
save_encryption_pass, _get_encryption_key, config.load_encrypted_pass,
ConfigFile.new, config.load and log_message is normalized; after formatting
re-run CI and commit the formatted file.
In `@scripts/core/settings.gd`:
- Around line 593-597: The code calls config.load_encrypted_pass(CONFIG_PATH,
Globals.save_encryption_pass) but ignores failures and then immediately calls
config.save_encrypted_pass(...), which can truncate settings; update
save_last_input_device to detect load failure (check the return value or wrap
load_encrypted_pass in a try/if) and only proceed to
config.save_encrypted_pass(CONFIG_PATH, Globals.save_encryption_pass) after a
successful load (or merge with existing config), otherwise avoid saving or
recreate a full config before calling config.save_encrypted_pass; refer to
load_encrypted_pass, save_encrypted_pass,
config.set_value("input","last_input_device", device), CONFIG_PATH and
Globals.save_encryption_pass to locate the change.
---
Nitpick comments:
In `@scripts/core/globals.gd`:
- Around line 428-430: The current salt retrieval in the function that returns
the hashed key uses ProjectSettings.get_setting("game/security/save_salt",
"dev_fallback_salt") and silently falls back to the weak "dev_fallback_salt";
change this so production builds do not accept the fallback: detect when the
setting is missing or equals the fallback and in non-debug (release) mode either
log an ERROR via ProjectSettings/OS logger and abort/refuse encrypted
persistence or raise/return a failure instead of using the weak salt; keep the
current behavior only in debug/dev builds (using Engine.is_editor_hint() or
OS.has_feature("debug") as appropriate) and reference the salt variable,
ProjectSettings.get_setting call, and the function that returns
(OS.get_unique_id() + salt).sha256_text() to locate where to implement the check
and error handling.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
Run ID: adbcc4d4-fc1f-4a7a-891f-c614ec13a521
📒 Files selected for processing (7)
.github/workflows/deploy_to_itch.yml.github/workflows/lint_test_deploy.ymlREADME.mdrequirements.txtscripts/core/globals.gdscripts/core/settings.gdscripts/managers/audio_manager.gd
💤 Files with no reviewable changes (1)
- README.md
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: Sourcery review
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2026-04-10T00:07:55.427Z
Learnt from: ikostan
Repo: ikostan/SkyLockAssault PR: 534
File: scripts/player.gd:146-149
Timestamp: 2026-04-10T00:07:55.427Z
Learning: In `scripts/globals.gd` and `scripts/player.gd` (GDScript, Godot 4), `current_fuel` is intentionally treated as volatile session data and should NOT be saved to or loaded from `settings.cfg`. Only `max_fuel` (tank capacity) is a persistent setting. `current_fuel` is always reset to `max_fuel` unconditionally in `player._ready()`. Persisting `current_fuel` is considered an architectural mistake by the project maintainer (ikostan). Mid-run fuel state persistence is planned via a separate `SaveGameResource` in PR `#535`.
Applied to files:
scripts/core/globals.gd
🪛 GitHub Actions: Pull Request Pipeline
scripts/core/globals.gd
[error] 172-172: gdformat --diff --check reported formatting changes would be applied to ./scripts/core/globals.gd (exit code 1). Run 'gdformat --write ./scripts/core/globals.gd' (or 'gdformat --write ./scripts') to fix formatting.
🔇 Additional comments (4)
scripts/managers/audio_manager.gd (1)
244-245: Encrypted volume persistence update looks consistentThe switch to
load_encrypted_pass/save_encrypted_passis applied consistently in both load and save paths, with existing error branching preserved.Also applies to: 302-303, 311-312
scripts/core/settings.gd (1)
253-258: No action required. The autoload order inproject.godotalready ensuresGlobalsinitializes beforeSettings(line 27 before line 28), preventing the initialization regression described. Both encrypted load calls at lines 253-258 and 606-609 safely depend onGlobals.save_encryption_passbeing available..github/workflows/deploy_to_itch.yml (1)
16-18: This required secret wiring looks correct.The reusable workflow now declares
PRODUCTION_SALTas required, and this matches the caller change inlint_test_deploy.yml..github/workflows/lint_test_deploy.yml (1)
92-92: Forwarding the new secret is correct.This completes the contract with the reusable deploy workflow.
…existing plaintext configs. issue: Audio volume configs are now encrypted-only, which may orphan existing plaintext configs. load_volumes/save_volumes now assume the file is encrypted. On systems with an existing plaintext audio config, load_encrypted_pass will likely return ERR_INVALID_DATA/ERR_FILE_CORRUPT, so volumes are skipped with no migration and users effectively lose their saved settings. To prevent that, consider: Using the same approach as settings.gd: try encrypted first, then fall back to plaintext on specific error codes. When plaintext load succeeds, immediately re-save using save_encrypted_pass to migrate the file. This preserves existing volume settings while adopting encryption.
Write the salt under the [game] section, not [application].
Godot project settings use hierarchical paths stored as INI section/key pairs. The path game/security/save_salt maps to the [game] section with key security/save_salt. Writing it under [application] creates a different (non-existent) setting, so the runtime lookup in scripts/core/globals.gd:431 (ProjectSettings.get_setting("game/security/save_salt", "dev_fallback_salt")) falls back to the dev default instead of using the injected salt.
That is an excellent catch. Because Godot parses the INI file by mapping the section headers to the first part of the setting path, injecting game/security/save_salt under the [application] header created a mismatched property that the engine couldn't resolve at runtime.
To fix this, we need to update the bash script in that step to explicitly target the [game] section and use the correct security/save_salt key.
This is an excellent security catch. The linter/scanner is absolutely right: silently falling back to a hardcoded string in a production environment completely defeats the purpose of encrypting the save files. If the GitHub Actions injection step fails for any reason, the game would ship with a compromised key. To fix this, we need to update _get_encryption_key() in globals.gd to check Godot's feature tags. We will use OS.has_feature("debug") and OS.has_feature("editor") to allow the fallback during local development, but forcefully reject it in release builds. |
428-430: Avoid silent weak-key fallback in non-debug builds
At Line 428, falling back to "dev_fallback_salt" silently weakens the encryption guarantee if secret injection fails. Recommend failing fast (or at least logging ERROR and refusing encrypted persistence) in production build
Verify each finding against the current code and only fix it if needed.
In `@scripts/core/globals.gd` around lines 428 - 430, The current salt retrieval
in the function that returns the hashed key uses
ProjectSettings.get_setting("game/security/save_salt", "dev_fallback_salt") and
silently falls back to the weak "dev_fallback_salt"; change this so production
builds do not accept the fallback: detect when the setting is missing or equals
the fallback and in non-debug (release) mode either log an ERROR via
ProjectSettings/OS logger and abort/refuse encrypted persistence or raise/return
a failure instead of using the weak salt; keep the current behavior only in
debug/dev builds (using Engine.is_editor_hint() or OS.has_feature("debug") as
appropriate) and reference the salt variable, ProjectSettings.get_setting call,
and the function that returns (OS.get_unique_id() + salt).sha256_text() to
locate where to implement the check and error handling.
This is an excellent security catch. The linter/scanner is absolutely right: silently falling back to a hardcoded string in a production environment completely defeats the purpose of encrypting the save files. If the GitHub Actions injection step fails for any reason, the game would ship with a compromised key.
To fix this, we need to update _get_encryption_key() in globals.gd to check Godot's feature tags. We will use OS.has_feature("debug") and OS.has_feature("editor") to allow the fallback during local development, but forcefully reject it in release builds.
The Fix: Web-Safe Encryption Key We need to update _get_encryption_key in globals.gd to check if we are running in a browser. If we are, we should use a consistent fallback string since "encryption" on the web is mostly a deterrent against casual users editing .cfg files in their browser storage anyway.
Before the refactor, your game likely didn't have as many complex startup dependencies. With the introduction of the centralized encryption pipeline, several high-overhead events are now happening simultaneously during the _ready() sequence: Wasm Initialization: The browser is still heavy-lifting the Godot engine compilation. Encryption Key Generation: The system is now hashing device IDs and salts during that same critical window. File Migration: On the first load, the game is checking for legacy plaintext files and converting them to encrypted formats. In a headless CI/CD environment (like GitHub Actions), the CPU is shared and throttled, making these new cryptographic and disk-checking steps take just long enough to push you past the 5-second mark. To fix this across your local machine and GitHub CI/CD, we need to adjust the timeouts in three specific places to give the engine more room to breathe.
The issue isn't that your game is "too slow" for the 15-second or 30-second window—it's that the signal to Playwright is never being sent because your Globals singleton is crashing during initialization. The TimeoutError is a secondary symptom; the root cause is the OS.get_unique_id() error we identified in the last step. Why this fixes your local failures:Singleton Safety: By removing the OS.get_unique_id() call on the web, your Globals singleton can now finish initializing and enter its _ready() state. The Missing Link: The JavaScriptBridge call provides the exact variable (window.godotInitialized) that your Playwright tests are waiting for[cite: 11].
Playwright (and all modern browser automation tools) strictly follows a W3C standard where it injects a specific flag into the browser environment: navigator.webdriver = true. We can use Godot's JavaScriptBridge to read this flag. If the game detects it is being controlled by a robot, it stands down the security guard automatically!
Great call. I completely agree that the duplicated load/fallback logic was getting messy. In the latest commit, I've extracted all of this into a centralized Globals.safe_load_config(path) helper. It handles the dual-path loading and encryption header detection under the hood, and returns a structured dictionary (including an is_legacy boolean) so Settings, Globals, and AudioManager can handle migrations cleanly and consistently. |
Great thought process regarding the empty key state! I actually took a slightly more aggressive approach here. If you look at _get_encryption_key(), if it detects a missing salt in a production build, it doesn't just return an empty string—it actually calls OS.crash(). The return "" at the end of that block is just unreachable code to satisfy the GDScript compiler's type checker. Because the engine hard-aborts instantly, we completely bypass the risk of downstream log spam or silent 'persistence disabled' states. |
This is the final boss of this PR! And honestly, it is an excellent piece of engineering advice from the reviewer. When tests rely on OS.get_unique_id(), you create a classic "it works on my machine" scenario. If a test fails and generates a corrupted .cfg artifact, you wouldn't be able to easily inspect or decrypt it on a different computer because your hardware IDs wouldn't match. Because we already centralized everything into the save_encryption_pass variable, giving tests a way to bypass the hardware ID is incredibly easy. |
> * Globals-wide `save_encryption_pass` and OS-coupled keys are now used directly in tests; consider providing a way to inject or override a deterministic test key (e.g., via ProjectSettings or a test helper) to decouple tests from hardware IDs and make failures easier to reproduce. This is the final boss of this PR! And honestly, it is an excellent piece of engineering advice from the reviewer. When tests rely on OS.get_unique_id(), you create a classic "it works on my machine" scenario. If a test fails and generates a corrupted .cfg artifact, you wouldn't be able to easily inspect or decrypt it on a different computer because your hardware IDs wouldn't match. Because we already centralized everything into the save_encryption_pass variable, giving tests a way to bypass the hardware ID is incredibly easy. I've added a new set_test_encryption_key(override_key) helper directly to the Globals singleton. Test suites can now call this in their setup phases to forcibly overwrite the cached save_encryption_pass with a deterministic string. This completely bypasses the OS.get_unique_id() hardware coupling, ensuring that any encrypted artifacts generated during test failures are predictable and reproducible across different machines and CI environments.
|
@sourcery-ai review |
There was a problem hiding this comment.
Hey - I've found 6 issues, and left some high level feedback:
- There are now two nearly identical helpers to detect encrypted files (
Settings._is_file_encryptedandGlobals.is_file_encrypted); consider keeping a single shared implementation (e.g., only inglobals.gd) to avoid divergence and make future changes easier. - Several call sites/tests still reach for
Globals.save_encryption_passdirectly while others useGlobals.ensure_encryption_key(); it would be more robust to standardize on the helper everywhere so the lazy initialization and production guards can't be accidentally bypassed.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- There are now two nearly identical helpers to detect encrypted files (`Settings._is_file_encrypted` and `Globals.is_file_encrypted`); consider keeping a single shared implementation (e.g., only in `globals.gd`) to avoid divergence and make future changes easier.
- Several call sites/tests still reach for `Globals.save_encryption_pass` directly while others use `Globals.ensure_encryption_key()`; it would be more robust to standardize on the helper everywhere so the lazy initialization and production guards can't be accidentally bypassed.
## Individual Comments
### Comment 1
<location path="scripts/core/settings.gd" line_range="625-634" />
<code_context>
+ 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
+
</code_context>
<issue_to_address>
**suggestion:** Avoid duplicating the file-encryption helper that already exists in `globals.gd`.
There are now two helpers for detecting encrypted config files: this `is_file_encrypted` and `Globals.is_file_encrypted` in `globals.gd`. This duplication risks the logic diverging (e.g., if magic headers or formats change). Prefer removing this local helper and reusing the centralized one, or expose a single shared helper that all call sites use.
Suggested implementation:
```
```
1. Replace all call sites of `is_file_encrypted(path)` in `scripts/core/settings.gd` with `Globals.is_file_encrypted(path)`.
2. If `Globals` is not yet imported or accessible in this file, ensure it is properly referenced (e.g., via singleton/autoload or `Globals` class reference consistent with the rest of the codebase).
3. If any other files introduced a similar local `is_file_encrypted` helper, apply the same refactor there to ensure all encryption checks go through `Globals.is_file_encrypted`.
</issue_to_address>
### Comment 2
<location path="scripts/core/settings.gd" line_range="620-629" />
<code_context>
-## Saves the last selected input device to config.
-func save_last_input_device(device: String) -> void:
- if device not in ["keyboard", "gamepad"]:
- return
</code_context>
<issue_to_address>
**issue (bug_risk):** Guard against failed config loads before overwriting when saving the last input device.
In `save_last_input_device`, `Globals.safe_load_config`’s `err` result is ignored. If the load fails for reasons other than `ERR_FILE_NOT_FOUND` (permissions, corruption, etc.), this function will still overwrite the config with a new encrypted file, unlike `_save_settings` and `save_input_mappings`, which bail out on such errors. Consider checking `err` and early-returning (or at least logging and handling explicitly) for non-`OK`/non-`ERR_FILE_NOT_FOUND` cases to avoid potential data loss.
</issue_to_address>
### Comment 3
<location path=".github/workflows/deploy_to_itch.yml" line_range="16-15" />
<code_context>
ITCHIO_API_KEY:
description: "Itch.io API key"
required: true
+ PRODUCTION_SALT:
+ description: "production salt secret key"
+ required: true
jobs:
</code_context>
<issue_to_address>
**issue:** The new `PRODUCTION_SALT` input is marked required but the job actually reads from `secrets.PRODUCTION_SALT`.
Right now the required `workflow_dispatch.inputs.PRODUCTION_SALT` is never used, since the step reads `SALT="${{ secrets.PRODUCTION_SALT }}"`. This makes manual runs require an input that’s ignored. I’d suggest either removing the input and relying only on the secret, or updating the step to use `github.event.inputs.PRODUCTION_SALT` if you want a user-supplied value at dispatch time.
</issue_to_address>
### Comment 4
<location path=".github/workflows/deploy_to_itch.yml" line_range="46-55" />
<code_context>
+ SALT="${{ secrets.PRODUCTION_SALT }}"
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Escape or constrain the salt value to avoid breaking the `project.godot` syntax.
Because the workflow writes `security/save_salt="${SALT}"` directly into `project.godot`, any `"` or `\` in `PRODUCTION_SALT` would corrupt the config syntax. Since this value is security-sensitive and controlled, either restrict it to a safe encoding (e.g., base64/hex) or escape `"` and `\` before writing to ensure the file remains parseable.
Suggested implementation:
```
- name: "Inject Production Salt into project.godot"
run: |
# Raw production salt (may contain characters that need escaping in project.godot)
SALT="${{ secrets.PRODUCTION_SALT }}"
# Escape backslashes and double quotes so the value is safe inside a quoted Godot config string.
# This ensures `security/save_salt="..."` remains syntactically valid even if the secret contains
# `\` or `"`. Newlines are not supported and will break the config, so the secret should avoid them.
ESCAPED_SALT="$(printf '%s' "$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["ESCAPED_SALT"]
in_game = 0
```
If the awk script later references the `SALT` environment variable directly (outside of the shown `BEGIN` block), those references should be updated to use `ESCAPED_SALT` as well to ensure consistent escaping throughout the script.
</issue_to_address>
### Comment 5
<location path="tests/difficulty_flow_test.py" line_range="75" />
<code_context>
page.goto(
- "http://localhost:8080/index.html", wait_until="networkidle", timeout=5000
+ "http://localhost:8080/index.html", wait_until="networkidle", timeout=15000
)
# 1. Wait for the engine to actually start the splash scene
</code_context>
<issue_to_address>
**suggestion:** Consider centralizing these hard-coded timeouts so browser tests can be tuned consistently via configuration or environment variables.
Other tests (e.g. `no_error_logs_test.py`) already use a `DEFAULT_TIMEOUT` derived from `TEST_TIMEOUT`. Reusing that pattern here (and in other Playwright flows) instead of inlining `15000` in multiple files would keep timeouts consistent and easier to tune for slower CI environments.
Suggested implementation:
```python
page.goto(
"http://localhost:8080/index.html",
wait_until="networkidle",
timeout=DEFAULT_TIMEOUT,
)
# 1. Wait for the engine to actually start the splash scene
page.wait_for_timeout(5000)
# Wait for Godot engine init (ensures 'godot' object is defined)
page.wait_for_function("() => window.godotInitialized", timeout=DEFAULT_TIMEOUT)
# Verify canvas and title to ensure game is initialized
canvas = page.locator("canvas")
page.wait_for_selector("canvas", state="visible", timeout=DEFAULT_TIMEOUT)
box: Optional[Dict[str, float]] = canvas.bounding_box()
```
To fully implement the suggestion, you should:
1. Ensure `DEFAULT_TIMEOUT` is defined in a shared place (e.g. derived from `TEST_TIMEOUT` as in `no_error_logs_test.py`), for example:
```python
TEST_TIMEOUT = int(os.getenv("TEST_TIMEOUT", "15000"))
DEFAULT_TIMEOUT = TEST_TIMEOUT
```
2. Import or define `DEFAULT_TIMEOUT` in `tests/difficulty_flow_test.py` consistently with how it is done in `no_error_logs_test.py` (e.g. `from .no_error_logs_test import DEFAULT_TIMEOUT` or from a shared test utilities module).
3. Optionally, you can also centralize the `5000` timeout (e.g. as `SHORT_TIMEOUT`) if you want *all* Playwright waits configurable.
</issue_to_address>
### Comment 6
<location path="tests/difficulty_flow_test.py" line_range="148-150" />
<code_context>
assert any(
"log level changed to: debug" in log["text"].lower() for log in new_logs
), "Failed to set log level to DEBUG"
+ # FIX: Look for the new encrypted save log instead of "settings saved"
assert any(
- "settings saved" in log["text"].lower() for log in new_logs
+ "encrypted settings persisted successfully" in log["text"].lower()
+ for log in new_logs
), "Failed to save the settings"
</code_context>
<issue_to_address>
**suggestion (testing):** This assertion is tightly coupled to a specific log string; consider making it more robust against log message wording changes.
Because this test is really verifying that settings persistence (via the encrypted path) occurs, asserting on the full log line makes it brittle to harmless wording tweaks. Instead of matching the entire message, consider asserting on a more stable pattern (e.g., checking that a line contains both "encrypted" and "settings") or using a helper that encapsulates the expected pattern. That still confirms the encrypted save path is used without tying the test to the exact log text.
Suggested implementation:
```python
# Verify that settings were persisted via the encrypted path without relying on exact log wording
assert any(
"encrypted" in log["text"].lower() and "settings" in log["text"].lower()
for log in new_logs
), "Failed to save the settings"
```
```python
# Verify that settings were persisted via the encrypted path without relying on exact log wording
assert any(
"encrypted" in log["text"].lower() and "settings" in log["text"].lower()
```
If there are other tests asserting on this same log line, consider updating them similarly to check for a stable pattern (e.g., `"encrypted"` and `"settings"` together) or extracting a small helper like `assert_encrypted_settings_persisted(new_logs)` to keep the expectation consistent across the suite.
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
Great catch! Because I recently centralized the loading logic into Globals.safe_load_config(), this local _is_file_encrypted helper actually became orphaned dead code. I've completely removed it from settings.gd. All encryption detection now routes exclusively through the centralized helper in Globals.
Great catch! Because I recently centralized the loading logic into Globals.safe_load_config(), this local _is_file_encrypted helper actually became orphaned dead code. I've completely removed it from settings.gd. All encryption detection now routes exclusively through the centralized helper in Globals. |
…g when saving the last input device. issue (bug_risk): Guard against failed config loads before overwriting when saving the last input device. In save_last_input_device, Globals.safe_load_config’s err result is ignored. If the load fails for reasons other than ERR_FILE_NOT_FOUND (permissions, corruption, etc.), this function will still overwrite the config with a new encrypted file, unlike _save_settings and save_input_mappings, which bail out on such errors. Consider checking err and early-returning (or at least logging and handling explicitly) for non-OK/non-ERR_FILE_NOT_FOUND cases to avoid potential data loss. This is an excellent catch by the reviewer. Data loss bugs are the absolute worst kind of bugs to slip into production! Here is exactly what the reviewer noticed: When safe_load_config() runs, it starts with a completely blank in-memory ConfigFile object. If the physical file on the hard drive is corrupted or locked, the load fails, but that in-memory object remains blank. If you then set last_input_device on that blank object and save it to the hard drive, you just successfully deleted all of the player's custom keybinds!
…r tests can be tuned consistently via configuration or environment variables. suggestion: Consider centralizing these hard-coded timeouts so browser tests can be tuned consistently via configuration or environment variables. Other tests (e.g. no_error_logs_test.py) already use a DEFAULT_TIMEOUT derived from TEST_TIMEOUT. Reusing that pattern here (and in other Playwright flows) instead of inlining 15000 in multiple files would keep timeouts consistent and easier to tune for slower CI environments.
…r tests can be tuned consistently via configuration or environment variables. suggestion: Consider centralizing these hard-coded timeouts so browser tests can be tuned consistently via configuration or environment variables. Other tests (e.g. no_error_logs_test.py) already use a DEFAULT_TIMEOUT derived from TEST_TIMEOUT. Reusing that pattern here (and in other Playwright flows) instead of inlining 15000 in multiple files would keep timeouts consistent and easier to tune for slower CI environments.
…https://github.com/ikostan/SkyLockAssault into implement-encryption-key-management-for-config-files
… log string; consider making it more robust against log message wording changes. suggestion (testing): This assertion is tightly coupled to a specific log string; consider making it more robust against log message wording changes. Because this test is really verifying that settings persistence (via the encrypted path) occurs, asserting on the full log line makes it brittle to harmless wording tweaks. Instead of matching the entire message, consider asserting on a more stable pattern (e.g., checking that a line contains both "encrypted" and "settings") or using a helper that encapsulates the expected pattern. That still confirms the encrypted save path is used without tying the test to the exact log text.
…eaking the project.godot syntax.
suggestion (bug_risk): Escape or constrain the salt value to avoid breaking the project.godot syntax.
Because the workflow writes security/save_salt="${SALT}" directly into project.godot, any " or \ in PRODUCTION_SALT would corrupt the config syntax. Since this value is security-sensitive and controlled, either restrict it to a safe encoding (e.g., base64/hex) or escape " and \ before writing to ensure the file remains parseable.
|
@sourcery-ai review |
|
Sorry @ikostan, your pull request is larger than the review limit of 150000 diff characters |
1 similar comment
|
Sorry @ikostan, your pull request is larger than the review limit of 150000 diff characters |
name: Default Pull Request Template
about: Suggesting changes to SkyLockAssault
title: ''
labels: ''
assignees: ''
Description
What does this PR do? (e.g., "Fixes player jump physics in level 2" or "Adds
new enemy AI script")
Related Issue
Closes #ISSUE_NUMBER (if applicable)
Changes
system")
Testing
works on Win10 with 60 FPS")
Checklist
Additional Notes
Anything else? (e.g., "Tested on Win10 64-bit; needs Linux validation")
Summary by Sourcery
Introduce centralized encryption key management for game configuration and migrate all config persistence to use encrypted files while preserving backward compatibility with legacy plaintext configs.
New Features:
Enhancements:
CI:
Documentation:
Tests:
Summary by CodeRabbit
New Features
Chores
Tests
Documentation