Obfuscating AES Keys in Self-Contained Rust Desktop Apps
Obfuscating AES Keys in Self-Contained Rust Desktop Apps If you ship a desktop app that bundles encrypted resources (a game, an Electron-style web app, a document pack), you have probably hit this wall: The key has to live in the binary, because the binary has to decrypt its own data at runtime. So most of us just do the naive thing. And then someone runs strings your.exe | grep -i key , or opens a hex editor, and the whole "encryption" collapses into a very expensive ZIP. This post shows a cheap, dependency-light upgrade: instead of storing the raw AES-256 key (and nonce) in the file, store a seed, derive an XOR mask from it with a deterministic PRNG, and only persist the masked bytes. The real key never appears in the packaged artifact. I implemented this in Nefu, a Rust tool that packages web projects (HTML/CSS/JS, plus a custom .nc declarative UI language) into single-file desktop executables. All code below is real, tested code from the repo. The threat model (be honest first) Let me be extremely clear about what this does and does not buy you: - β Stops the casual extraction path: strings , hexdump + copy-paste, "just grep for 32 random bytes". - β Raises the bar enough that a curious user gives up. - β Does NOT stop a determined reverse engineer. Anyone with a debugger can dump the key from memory at runtime, or trace the AES round keys. True protection against that is white-box cryptography, which is an order of magnitude heavier. If you are building DRM, stop reading and go look at white-box AES or a licensing service. If you are building "I don't want my game's assets to be trivially unpackable," this is a sweet spot: ~60 lines of code, zero new dependencies. The old layout (the problem) Nefu packages resources like this: [Original executable] [AES-256-GCM encrypted ZIP] [8 bytes: encrypted length (u64 LE)] [32 bytes: SHA-256 checksum] [8 bytes: magic "NEFUPACK"] And the encrypted payload was assembled as: // build_executable, before let mut encrypted_package = Vec::with_capacity(AES_KEY_LEN + NONCE_LEN + ciphertext.len()); encrypted_package.extend_from_slice(&aes_key); // Vec { // Domain-separated seed diffusion let mut hasher = Sha256::new(); hasher.update(seed); hasher.update(b"::"); hasher.update(domain); hasher.update(b"::nefu-obf-v1"); let digest = hasher.finalize(); let mut state = u64::from_le_bytes(digest[..8].try_into().unwrap()); let mut mixer = u64::from_le_bytes(digest[8..16].try_into().unwrap()); let mut counter = u64::from_le_bytes(digest[16..24].try_into().unwrap()); let mut mask = Vec::with_capacity(len); while mask.len() > 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); z ^= z >> 31; // Auxiliary mixer: xorshift64* variant mixer ^= mixer >> 12; mixer ^= mixer > 27; mixer = mixer.wrapping_mul(0x2545_F491_4F6C_DD1D); // Third stream: counter-fed avalanche PRNG counter = counter.wrapping_add(0x9E37_79B9_7F4A_7C15); let mut c = counter; c = (c ^ (c >> 33)).wrapping_mul(0xFF51_AFD7_ED55_8CCD); c ^= c >> 33; let blended = z ^ mixer.rotate_left(23) ^ c.rotate_left(41); mask.extend_from_slice(&blended.to_le_bytes()); } mask.truncate(len); mask } The seed goes through SHA-256 with a domain separator before any PRNG state is derived, so: - the same (seed, domain, length) always produces the same mask (that is the whole point - packer and unpacker agree), - different domains produce uncorrelated streams (key mask ≠ nonce mask), - different seeds produce totally different masks. Obfuscate / deobfuscate fn obfuscate_secret(seed: &[u8], secret: &[u8], domain: &[u8]) -> Vec { let mask = generate_obfuscation_mask(seed, secret.len(), domain); secret.iter().zip(&mask).map(|(s, m)| s ^ m).collect() } fn deobfuscate_secret(seed: &[u8], obfuscated: &[u8], domain: &[u8]) -> Vec { obfuscate_secret(seed, obfuscated, domain) } XOR is its own inverse, so one function serves both directions. (Yes, I keep both names in the codebase for readability - the compiler inlines them anyway.) The new payload layout Old: [key 32B][nonce 12B][ciphertext ...] New: [OBS1][seed 16B][masked_key 32B][masked_nonce 12B][ciphertext ...] OBS1 is a 4-byte format marker. It does two jobs: - Lets unpack detect which layout it is looking at. - Gives us a cheap future migration path ( OBS2 , ...). Packing side // Step 2: generate a random key, nonce and obfuscation seed let mut rng = rand::thread_rng(); let mut aes_key = [0u8; AES_KEY_LEN]; let mut nonce_bytes = [0u8; NONCE_LEN]; let mut obf_seed = [0u8; OBF_SEED_LEN]; rng.fill(&mut aes_key); rng.fill(&mut nonce_bytes); rng.fill(&mut obf_seed); // Step 2b: obfuscate key and nonce so the raw bytes never hit the file let obf_key = obfuscate_secret(&obf_seed, &aes_key, b"key"); let obf_nonce = obfuscate_secret(&obf_seed, &nonce_bytes, b"nonce"); // Step 3: AES-256-GCM encrypt let cipher = Aes256Gcm::new_from_slice(&aes_key)?; let nonce = Nonce::from_slice(&nonce_bytes); let ciphertext = cipher.encrypt(nonce, zip_data.as_ref())?; // Assemble: [OBS1][seed][obf_key][obf_nonce][ciphertext] let mut encrypted_package = Vec::with_capacity( FORMAT_MARKER.len() + OBF_SEED_LEN + AES_KEY_LEN + NONCE_LEN + ciphertext.len(), ); encrypted_package.extend_from_slice(FORMAT_MARKER); encrypted_package.extend_from_slice(&obf_seed); encrypted_package.extend_from_slice(&obf_key); encrypted_package.extend_from_slice(&obf_nonce); encrypted_package.extend_from_slice(&ciphertext); payload = encrypted_package; Unpacking side if encrypted_data.starts_with(b"PK\x03\x04") { // --no-encrypt debug artifact: plaintext ZIP, skip decryption decompressed = encrypted_data.to_vec(); } else if encrypted_data.starts_with(FORMAT_MARKER) { // ---- v1 obfuscated layout ---- let rest = &encrypted_data[FORMAT_MARKER.len()..]; let obf_seed = &rest[..OBF_SEED_LEN]; let obf_key = &rest[OBF_SEED_LEN..OBF_SEED_LEN + AES_KEY_LEN]; let obf_nonce = &rest[OBF_SEED_LEN + AES_KEY_LEN..OBF_SEED_LEN + AES_KEY_LEN + NONCE_LEN]; let ciphertext = &rest[OBF_SEED_LEN + AES_KEY_LEN + NONCE_LEN..]; // Recover the real key and nonce from the masked forms let key = deobfuscate_secret(obf_seed, obf_key, b"key"); let nonce_bytes = deobfuscate_secret(obf_seed, obf_nonce, b"nonce"); let cipher = Aes256Gcm::new_from_slice(&key)?; let nonce = Nonce::from_slice(&nonce_bytes); decompressed = cipher.decrypt(nonce, ciphertext)?; } else { // ---- legacy layout (backward compatibility) ---- // [key 32B][nonce 12B][ciphertext ...] - still supported } Note the PK\x03\x04 check first: --no-encrypt debug builds ship a plain ZIP, and that path is unchanged. Backward compatibility This was the sneaky part. Existing users already shipped executables with the legacy [key][nonce][ciphertext] layout. The new binary must still run those. The OBS1 marker makes this trivial - the unpacker branches on the first 4 bytes. The legacy branch is byte-for-byte the old code, and I added a regression test that hand-assembles a legacy payload (with a real ZIP inside) and asserts it unpacks correctly. One caveat: I didn't bump the file magic, so verify /extract metadata tooling needed no changes. The trailer ([len][checksum][magic] ) is untouched. The test suite Because this is security-adjacent code, I treated tests as part of the deliverable: test_obfuscation_mask_deterministic // same seed+domain => same mask test_obfuscation_mask_domain_separated // "key" mask != "nonce" mask test_obfuscation_mask_seed_sensitive // different seed => different mask test_obfuscate_deobfuscate_roundtrip // masked -> unmasked == original test_unpack_legacy_layout_compatibility // old binaries still unpack test_build_then_unpack_obfuscated_key_not_plaintext // OBS1 marker + full roundtrip test_build_then_unpack_roundtrip // existing e2e regression Full suite: 190 tests, all passing (including these plus the pre-existing global shortcut, hot-reload injection, and packaging roundtrip tests). Two of those tests caught real issues during this change: - The legacy-layout test initially used a fake ZIP body and failed on failed to open ZIP archive - the test itself was wrong, not the code. Fixing it to encrypt a real ZIP made the compatibility guarantee actually meaningful. - A pre-existing test in global_shortcut.rs calledhotkey.modifiers() , which doesn't exist inglobal-hotkey 0.6.4 (the API moved tomatches() /into_string() ). It had been silently broken becausecargo test was never green on this crate. I fixed it so the suite is actually runnable and green. Honest limitations - Memory dumping beats everything. Attach a debugger, dump the process, find the AES key in the heap. Obfuscation-at-rest cannot prevent this. If you need protection against live extraction, you need white-box crypto and/or an external licensing service. - The seed is in the binary. The scheme is obfuscation, not hiding. A determined analyst will find the PRNG, reproduce the mask, and decrypt. We are raising the bar, not eliminating the problem. - Don't overclaim to your users. "Encrypted and protected" is true against casual inspection; against a skilled reverser it is "mildly annoying." Takeaways - If you embed a key next to the data it decrypts, you have a ZIP, not encryption. At minimum, XOR-mask it with a derived stream so it isn't sitting there in cleartext. - Deterministic PRNG + domain separator is a zero-dependency, testable way to do this. SplitMix64 is 15 lines and good enough for mask generation (it's not being used as a cryptographic primitive - AES-GCM still provides the actual confidentiality). - Format markers ( OBS1 ) make schema migration painless and keep backward compatibility testable. - Write the tests for the security property you actually care about: "the raw key never appears in the artifact" is a testable assertion, and "old artifacts still unpack" is too. The full implementation lives in the Nefu repository (src/pack.rs ), with the complete test suite. If you ship self-contained desktop apps with bundled resources, stealing this ~60-line pattern is a strict up
Comments
No comments yet. Start the discussion.