Repository navigation
Add config obfuscation (T9f) - #6
Merged
Merged
Conversation
A config file can now be plain JSON or obfuscated Base64, matching the other
SDKs' "plain or encrypted" behaviour. The loader tries JSON first, because a
JSON document is never valid Base64, then falls back to deobfuscating.
This is obfuscation, not encryption, and the header says so in the first
paragraph. The key is compiled into the binary, so anyone with a debugger can
recover it. Its only job is to stop a player casually reading an API key out of
a text file. R29 exists because the other SDKs describe this as encryption and
adopters believe them.
Not AES, deliberately. Both engine SDKs use AES with a hardcoded key and they
are not compatible with each other - Unity is AES-128-CBC with an IV prefix,
Unreal is AES-256-ECB with PKCS7 - so "port it" has no single answer. Matching
either means adding a crypto implementation to core, which every adopter links,
against D2's source-only drop-in promise and the spec's refusal to require a
package manager. A vendored AES would also be a security-shaped dependency that
invites the reader to believe the result is secure, which is the exact
misreading R29 is about.
So it is a fixed-key XOR stream plus Base64: honest, small, no dependency, no
pretence. If an adopter needs real protection the answer is OS key storage or
an injected seam, not a stronger cipher with a key in the binary.
Deterministic on purpose. A random IV would mean the file changes every time it
is written, which shows up in every diff. Identical plaintext producing
identical output is fine for obfuscation and would not be for encryption.
The empty string is asymmetric and that is deliberate: Base64Encode("") is "",
which is correct Base64, but IsValidBase64("") is false because its caller is
the config loader and an empty file is not a config. Asserted explicitly rather
than left as a surprise.
Verified by breaking the XOR (9 assertions fail) and the Base64 padding (14
fail). Both boundary checks clean, all 8 suites pass, zero warnings.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Contributor
There was a problem hiding this comment.
🟡 Changes recommended
Active issues remain: obfuscated loads return decoded JSON instead of the documented original raw body, and the public API documentation does not describe the newly accepted Base64 format or its errors.
2 open issues
What changed in this PR
Adds optional, dependency-free XOR-plus-Base64 obfuscation for configuration files while preserving plain JSON support and integrating the behavior into the core library.
Changes:
- Added Base64 encoding, decoding, validation, and fixed-key XOR obfuscation utilities.
- Updated configuration loading to accept plain JSON or obfuscated Base64 input.
- Added build integration and unit tests covering round trips, malformed input, loader behavior, and error handling.
| File | Description |
|---|---|
| tests/test_config_obfuscation.cpp | Adds unit tests for Base64 processing, XOR obfuscation, validation, configuration loading, and error handling. |
| tests/CMakeLists.txt | Registers the configuration obfuscation test executable and its CTest suite. |
| src/core/config_obfuscation.cpp | Implements dependency-free Base64 processing and fixed-key XOR obfuscation. |
| src/core/config_file.cpp | Adds support for loading plain JSON or obfuscated Base64 configuration files. |
| include/lootlocker/config_obfuscation.h | Defines the public Base64 and configuration obfuscation APIs with documented security limitations. |
| CMakeLists.txt | Adds the configuration obfuscation implementation to the core library target. |
Review effort: Lite
RawBody() returned the decoded JSON on the obfuscated path, contradicting the contract in config_file.h that promises the file's original text. The plain-JSON path honoured it; the obfuscated path did not. Worse, the test asserted the deviation, so it locked in the wrong behaviour rather than catching it. The contract wins: an adopter can decode Base64 themselves, but cannot recover the original bytes once the loader replaces them. The obfuscated path now passes the original input through, and the test asserts equality with the Base64 rather than merely checking that the decoded JSON appears. Verified by reverting the fix and watching two assertions fail. The header also under-described the format. It said the file is JSON and listed three error cases, never mentioning that Base64-obfuscated input is now accepted or the "neither JSON nor Base64" error. It now documents both accepted forms, the full error list, and what RawBody() carries on each path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
kirre-bylund
force-pushed
the
feat/config-obfuscation
branch
from
November 13, 2026 12:01
ad52cbc to
980891c
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.


Implements T9f. A config file can now be plain JSON or obfuscated Base64, matching the other SDKs' "plain or encrypted" behaviour.
The decision I had to make, and why
"Port it" turned out to be ambiguous. Both engine SDKs use AES with a hardcoded key, and they are not compatible with each other: Unity is AES-128-CBC with an IV prefix, Unreal is AES-256-ECB with PKCS7. So there was no single thing to port — the real question was whether to take on a crypto dependency at all.
I decided against AES. AES is not in the C++ standard library, so matching either engine means adding a crypto implementation to
core, which every adopter links. That is against D2's source-only drop-in promise and the spec's refusal to require a package manager.A vendored AES would be worse than no AES, because it is a security-shaped dependency that invites the reader to believe the result is secure. That is precisely the misreading R29 exists to prevent. A construction that looks like obfuscation and is documented as obfuscation is safer than one that looks like encryption and is not.
So it is a fixed-key XOR stream plus Base64: honest, small, no dependency, no pretence. The header says "this is obfuscation, not encryption" in its first paragraph, and says that an adopter who needs real protection should get OS key storage or an injected seam rather than a stronger cipher with a key in the binary.
If you would rather have AES, say so and I will redo it — but it needs a decision on which format to match, since the two engines disagree.
Other decisions worth knowing
Base64Encode("")is"", which is correct Base64, butIsValidBase64("")is false because its caller is the config loader and an empty file is not a config. Asserted explicitly rather than left as a surprise.Verification
-DLOOTLOCKER_MODULE_ONLYstill configures forcoreandauth;broadcasts.Spec
lootlocker/index#1901 records the decision, and closes out T9d and T9e which merged in #5 but were still marked "awaiting merge".