Table of Contents
Introduction
π» HACKER It is common that software needs to have some sensitive secret information that is not to be shared by anyone but the developer or development team. In some cases, not even developers, users, or devices are to be trusted with sensitive information (i.e. zero trust).

Secrets can be passwords, API keys/tokens, encryption keys, or personal information. Eventually, in our case for this project, we will store and use WiFi network credentials and API Keys.
Obviously, secrets are different from configurations in that we aim to keep secrets exposure as limited as possible with a variety of techniques. As opposed to secrets, configurations are more openly visible to be changed.
Embedded systems like our Raspberry Pi Pico face unique challenges: they lack traditional filesystems or OS security features, and have limited memory for complex encryption. Additionally, physical device access often enables direct memory extraction through debug interfaces, making secret protection particularly challenging.
In the following sections, we will outline and implement a simple method to store our program secrets. This technique is very similar to our previous configuration management, except that we are keeping secrets separate, obfuscated, and out of our GitHub.com repository.
For more serious secret storage, please review the very last section of this tutorial part: Serious Secret Storage Alternatives
File Changes
For a quick indicator of what files we will add or change in this part, expand the following file tree.
16 collapsed lines
.βββ build.rs # <--- Changesβββ .cargoβΒ Β βββ config.tomlβββ Cargo.lockβββ Cargo.toml # <--- Changesβββ configs.jsonβββ .gitβββ .gitignore # <--- Changesβββ memory.xβββ README.md # <--- Changesβββ .rustfmt.tomlβββ rust-toolchain.tomlβββ secrets.json # <--- Addedβββ src βββ main.rs # <--- Changes βββ utility.rs # <--- Added
.gitignore
Letβs begin with one of the most important parts in all of this: Ensuring that we do not commit any secrets into git history and GitHub.com.
Accidentally committing sensitive files to git history happens ALL THE TIME. So much so that it has become the source for a new activity called dorking, where individuals leverage public search engines such as GitHub search to discover hidden secrets within public code projects.
Maybe we can prevent ourselves becoming targets of dorking and
just go ahead and modify our .gitignore
file.
All we are going to add is the following .gitignore
pattern,
recursively ignoring any file containing the word secret
in it.
# ....
# Any secrets files**/*secret*
secrets.json
As before with the configuration file, we will add a secrets.json
file
that will hold all our precious sensitive secrets that will be used
within our embedded Rust program. Remember, this file should only live
within this project locally on your computer.
We are simply adding a initial temporary dummy secret so we can develop our secrets loading code.
{ "super_secret_info": "Area51HasNoAliens"}
build.rs
Here again, as with the configuration file, we are adding logic to read
and parse the secrets.json
file. This in turn will result in a secrets.rs
Rust file that is available to the Rust program at runtime.
The good news is that we already have the same code to
read and parse the configs.json
. Hence, we do not need any
additional dependencies to import and we are able to
copy the same logic we have already written.
Letβs start off by defining the function that reads and parses
the secrets.json
file. We will copy logic from our already existing
configuration loading code, while renaming variables and
definitions appropriately.
// ....
/// Loads secret values from secrets.json and generates a secret.rs file/// with Rust obfuscated constants for use in the embedded application////// # Arguments/// * `out_dir` - The target build directory path/// * `secret_keys` - Array of configuration keys to extract from secrets.jsonfn load_secrets(out_dir: &str, secret_keys: [&str; 1]) -> io::Result<()> { println!("[BUILD TASK] LOADING OBFUSCATED SECRETS"); println!("Secrets output directory: {out_dir:?}");
println!("Creating new blank 'secrets.rs' file within the output directory ..."); let dest_path = Path::new(&out_dir).join("secrets.rs"); let mut output_file = fs::File::create(dest_path)?;
let project_root_dir = env::var("CARGO_MANIFEST_DIR").unwrap(); let secrets_path = Path::new(&project_root_dir).join("secrets.json");
println!("Looking for secrets file at filepath: {secrets_path:?}"); if !secrets_path.exists() { let error_message = "Failed to find 'secrets.json' in the project root directory. Please create a 'secrets.json' file with your secret values."; return Err(io::Error::new(io::ErrorKind::NotFound, error_message)); };
println!("Reading and parsing 'secrets.json' ..."); let contents_raw_string = fs::read_to_string(secrets_path)?; let secret_values: serde_json::Value = serde_json::from_str(&contents_raw_string)?;
// Verify all keys exist for secret_key in secret_keys.iter() { if !secret_values.as_object().unwrap().contains_key(*secret_key) { let error_message = format!("Key '{}' not found in 'secrets.json' file", secret_key); return Err(io::Error::new(io::ErrorKind::InvalidData, error_message)); } }
Ok(())}
Now, letβs add a simple way to obfuscate the secret constants that we just loaded.
Here, obfuscation means that we are ensuring that the secret value does not appear as itself within the final embedded program binary, but as some random-looking text.
We do not want someone to be able to copy our local
build binary (i.e. within target/
) or extract our deployed binary
from the device, and search it for obvious secrets patterns
with something like the following.
# Use `strings` to output any string text before searching anything with "area"strings extracted_firmware.bin | grep -i "area"
# Output:# Area51HasNoAliens
WARNING
Although this is perfectly fine for hobby or prototype projects, simply XOR cipher obfuscating a secret is not enough for any serious type of embedded secrets storage. A moderately capable attacker can easily extract these obfuscated secrets.
Letβs first define a XOR key for secret obfuscation. This XOR obfuscation hides text by mathematically scrambling it with a constant key, where applying the same scrambling twice reveals the original text.
// ....
6 collapsed lines
/// Loads secret values from secrets.json and generates a secret.rs file/// with Rust obfuscated constants for use in the embedded application////// # Arguments/// * `out_dir` - The target build directory path/// * `secret_keys` - Array of configuration keys to extract from secrets.jsonfn load_secrets(out_dir: &str, secret_keys: [&str; 1]) -> io::Result<()> {28 collapsed lines
println!("[BUILD TASK] LOADING OBFUSCATED SECRETS"); println!("Secrets output directory: {out_dir:?}");
println!("Creating new blank 'secrets.rs' file within the output directory ..."); let dest_path = Path::new(&out_dir).join("secrets.rs"); let mut output_file = fs::File::create(dest_path)?;
let project_root_dir = env::var("CARGO_MANIFEST_DIR").unwrap(); let secrets_path = Path::new(&project_root_dir).join("secrets.json");
println!("Looking for secrets file at filepath: {secrets_path:?}"); if !secrets_path.exists() { let error_message = "Failed to find 'secrets.json' in the project root directory. Please create a 'secrets.json' file with your secret values."; return Err(io::Error::new(io::ErrorKind::NotFound, error_message)); };
println!("Reading and parsing 'secrets.json' ..."); let contents_raw_string = fs::read_to_string(secrets_path)?; let secret_values: serde_json::Value = serde_json::from_str(&contents_raw_string)?;
// Verify all keys exist for secret_key in secret_keys.iter() { if !secret_values.as_object().unwrap().contains_key(*secret_key) { let error_message = format!("Key '{}' not found in 'secrets.json' file", secret_key); return Err(io::Error::new(io::ErrorKind::InvalidData, error_message)); } }
// XOR Key - Alternating bit pattern (10100101) for simple, reversible obfuscation let xor_key: u8 = 0xA5; println!("Writing XOR key and obfuscated secrets to 'secrets.rs' ..."); writeln!(output_file, "pub const XOR_KEY: u8 = 0x{:02X};", xor_key)?;
Ok(())}
Next, we will add the code that iterates over each secret, obfuscates it,
and writes it to the secrets.rs
file. Note the code b ^ xor_key
, that
applies the bitwise exclusive OR (a.k.a XOR)
operator (^
) to each secret with a hexadecimal
value of 0xA5
(binary: 10100101
).
// ....
6 collapsed lines
/// Loads secret values from secrets.json and generates a secret.rs file/// with Rust obfuscated constants for use in the embedded application////// # Arguments/// * `out_dir` - The target build directory path/// * `secret_keys` - Array of configuration keys to extract from secrets.jsonfn load_secrets(out_dir: &str, secret_keys: [&str; 1]) -> io::Result<()> {28 collapsed lines
println!("[BUILD TASK] LOADING OBFUSCATED SECRETS"); println!("Secrets output directory: {out_dir:?}");
println!("Creating new blank 'secrets.rs' file within the output directory ..."); let dest_path = Path::new(&out_dir).join("secrets.rs"); let mut output_file = fs::File::create(dest_path)?;
let project_root_dir = env::var("CARGO_MANIFEST_DIR").unwrap(); let secrets_path = Path::new(&project_root_dir).join("secrets.json");
println!("Looking for secrets file at filepath: {secrets_path:?}"); if !secrets_path.exists() { let error_message = "Failed to find 'secrets.json' in the project root directory. Please create a 'secrets.json' file with your secret values."; return Err(io::Error::new(io::ErrorKind::NotFound, error_message)); };
println!("Reading and parsing 'secrets.json' ..."); let contents_raw_string = fs::read_to_string(secrets_path)?; let secret_values: serde_json::Value = serde_json::from_str(&contents_raw_string)?;
// Verify all keys exist for secret_key in secret_keys.iter() { if !secret_values.as_object().unwrap().contains_key(*secret_key) { let error_message = format!("Key '{}' not found in 'secrets.json' file", secret_key); return Err(io::Error::new(io::ErrorKind::InvalidData, error_message)); } }
// XOR Key - Alternating bit pattern (01011010) for simple, reversible obfuscation let xor_key: u8 = 0xA5; println!("Writing XOR key and obfuscated secrets to 'secrets.rs' ..."); writeln!(output_file, "pub const XOR_KEY: u8 = 0x{:02X};", xor_key)?;
// Write the obfuscated constants to secrets.rs for secret_key in secret_keys.iter() { print!(" - {} (obfuscated)", secret_key.to_uppercase());
let secret_value = secret_values[secret_key].as_str().unwrap();
// XOR encode the secret let obfuscated: Vec<u8> = secret_value.bytes().map(|b| b ^ xor_key).collect();
// Write the obfuscated constant as a byte array writeln!( output_file, "pub const {}_OBFUSCATED: &[u8] = &{:?};", secret_key.to_uppercase(), obfuscated )?;
println!(" ... obfuscated and stored!") }
println!("Successfully loaded and obfuscated all secrets from 'secrets.json'!");
Ok(())}
Our last piece for this build.rs
file will be to actually implement
this new load_secrets()
function. This again, is the same as before
with configuration loading.
12 collapsed lines
//! This build script runs during code compilation//! Runs on host computer, where `std` Rust tools are available here//! Reference: https://doc.rust-lang.org/cargo/reference/build-scripts.html
use std::env;use std::fs;use std::io;use std::io::Write;use std::path::Path;use std::path::PathBuf;
/// Main build script entry pointfn main() {9 collapsed lines
// Put `memory.x` in our output directory let out_dir = &PathBuf::from(env::var_os("OUT_DIR").unwrap()); fs::File::create(out_dir.join("memory.x")) .unwrap() .write_all(include_bytes!("memory.x")) .unwrap();
// Ensure 'memory.x' is on the linker search path println!("cargo:rustc-link-search={}", out_dir.display());
// Re-run for any changes to the following files println!("cargo:rerun-if-changed=build.rs"); println!("cargo:rerun-if-changed=memory.x"); println!("cargo:rerun-if-changed=configs.json"); println!("cargo:rerun-if-changed=secrets.json");
// Load configurations from local 'configs.json' file let configs_keys = ["number_of_messages"]; load_configs(out_dir.to_str().unwrap(), configs_keys) .unwrap_or_else(|error| panic!("[ERROR] {error:?}"));
// Load secrets from local 'secrets.json' file with XOR obfuscation let secrets_keys = ["super_secret_info"]; load_secrets(out_dir.to_str().unwrap(), secrets_keys) .unwrap_or_else(|error| panic!("[ERROR] {error:?}"));}
// ....
Cargo.toml
For the next part, and for many subsequent parts, we will need better
support for our limited embedded #![no_std]
environment without dynamic
memory allocation (i.e. the heap).
We only have a fixed amount of memory available on our device, allocating space as we go will not work very well. Hence, we need an alternative that sets exactly how much memory we need at runtime.
Luckily, the heapless
Rust crate provides a solution!
To utilize common and useful dynamic Rust data structures such as
String
and Vec
, the heapless
crate offers heapless::String
and heapless::Vec
for us to use.
(More info
on using heapless
)
Letβs add this crate as a runtime dependency for our project.
# ....
######################################################################################### Rust program dependencies# Reference: https://doc.rust-lang.org/cargo/guide/dependencies.html[dependencies]cortex-m = "0.7" # Core support for ARM Cortex-M microcontrollers (M33 for RP2350)cortex-m-rt = "0.7" # Startup code and minimal runtime for Cortex-Mdefmt = "1.0" # Deferred formatting for embedded loggingdefmt-rtt = "1.0" # RTT (Real-Time Transfer) transport for defmtheapless = "0.9" # Fixed-size collections for no_std environmentspanic-halt = "1.0" # Basic panic handler that halts on panic
# ....
src/utility.rs
Once our program starts running within the Raspberry Pi Pico device, we need a way to load the secrets.
We will add a helper function that is able to read and de-obfuscate
our loaded secrets using the originally stored XOR_KEY
. Specifically,
this function will simply apply another exclusive OR to our stored
secret text as bytes.
//! Utility functions for the embedded application//! This module contains helper functions that can be used throughout the project
use heapless::String;
use crate::XOR_KEY;
/// Deobfuscates a byte array that was XOR encoded with XOR_KEY////// This function takes the obfuscated bytes and XORs each one with/// the XOR_KEY to recover the original secret string.////// # Arguments/// * `obfuscated` - A byte slice containing XOR-obfuscated data////// # Returns/// A heapless String containing the deobfuscated text (max 256 chars)pub fn deobfuscate(obfuscated: &[u8]) -> String<256> { let mut result = String::new();
for &byte in obfuscated { // XOR the byte with our key to get original string constant let original_char = (byte ^ XOR_KEY) as char; let _ = result.push(original_char); } result}
Because we are running this with limited #![no_std]
environment,
the function here returns a heapless
String of
fixed max size of 256 characters. In turn, any value coming out of this function
will typically need a .as_str()
to make it a string slice that we can easily use.
src/main.rs
Now we are getting to the final part of tying this whole secrets thing together!
Here we are loading all obfuscated secrets from the generated secrets.rs
file.
After that, we are simply printing the secret out to the console.
But definitely do not ever log secrets to the console. This is only done here for simple demonstration purposes and will be changed in the subsequent parts.
8 collapsed lines
#![no_std]#![no_main]#![allow(unused_variables)] // only used for development#![allow(unused_imports)] // only used for development
use defmt::*;use defmt_rtt as _;use panic_probe as _;
use embassy_executor::Spawner;use embassy_rp::config::Config;use embassy_rp::gpio;use embassy_time::Timer;
mod utility;
4 collapsed lines
// Loading configurations// Note: This comes from the 'configs.rs' file created in 'build.rs' from 'configs.json'// Note: All loaded configurations will be UPPERCASE string slice (&str) constantsinclude!(concat!(env!("OUT_DIR"), "/configs.rs"));
// Loading obfuscated secrets (XOR_KEY and _OBFUSCATED constants)// Note: Similar considerations as configurations// Note: Load via utility.deobfuscate()include!(concat!(env!("OUT_DIR"), "/secrets.rs"));
#[embassy_executor::main]async fn main(spawner: Spawner) {13 collapsed lines
// Initialize system with proper clock configuration let config = Config::default(); let _peripherals = embassy_rp::init(config);
// Parse and define a configuration value let number_of_messages = NUMBER_OF_MESSAGES.parse::<u8>().unwrap();
info!("Number of Messages: {}", number_of_messages);
for index in 1..=number_of_messages { info!("Hello there - {}", index); Timer::after_secs(1).await; }
// Deobfuscate secret (The utility module accesses XOR_KEY) let super_secret_info = utility::deobfuscate(SUPER_SECRET_INFO_OBFUSCATED);
// WARNING: Never log secrets! This is for demonstration only info!("Super Secret Info: {}", super_secret_info.as_str());
// Start an infinite loop loop { Timer::after_secs(5).await; }}
Letβs Test It Out!
Ensure our Raspberry Pi Pico 2 W and its debug probe are plugged in, connected correctly, and recognized by your computer. Then run the following command.
cargo run --release
The resulting terminal output should hopefully look like this:
Compiling big-button v0.1.0 (/home/you/projects/big-button) Finished `release` profile [optimized + debuginfo] target(s) in 0.70s Running `probe-rs run --chip=RP235x --log-format '[{t}] {[{L}]%dimmed%bold} {{f:dimmed}:{l:dimmed}%30}: {s}' target/thumbv8m.main-none-eabihf/release/big-button` Erasing β 100% [####################] 12.00 KiB @ 51.82 KiB/s (took 0s) Finished in 0.65s[0.000359] [INFO ] main.rs:36 : Number of Messages: 5[0.000390] [INFO ] main.rs:39 : Hello there - 1[1.000424] [INFO ] main.rs:39 : Hello there - 2[2.000439] [INFO ] main.rs:39 : Hello there - 3[3.000454] [INFO ] main.rs:39 : Hello there - 4[4.000469] [INFO ] main.rs:39 : Hello there - 5[5.000504] [INFO ] main.rs:45 : Super Secret Info: Area51HasNoAliens
Nice! Once everything is working correctly, donβt forget to git
commit your changes
and push them to GitHub.com.
Documentation
And as always, letβs add some documentation. We will add information to
the README.md
file that describes how specific secrets are used in
this project.
# big-button
18 collapsed lines
Just some interesting embedded Rust tutorial series I found floating aroundthe internet that I wanted to try.
Run me with `cargo run --release`
## Configurations
This project contains a `configs.json` file that contains variousconfiguration values for this program. The program will load thesevalues during the build process.
Currently the following configurations are supported:
'''json{ "number_of_messages": "5"}'''
## Secrets
Create and fill in a `secrets.json` file that contains sensitivesecret constants used by this project to run correctly.
Currently the following secrets are needed:
'''json{ "super_secret_info": "Area51HasNoAliens"}'''
Serious Secret Storage Alternatives

NOTE
The goal for this section is not to get into specifics. However, it is to just make you aware of more serious alternatives. Because I am presenting a simplified secret management technique, I feel obligated to mention serious alternatives. None of the following security techniques will relate to our project.
For more serious projects such as military, financial, medical, or commercial products, you might (among other things) consider the following techniques to protect software secrets. Note that each one of these has its appropriate usage, positives, and negatives. Also note that you may even use these in combination.
Encryption
Encryption transforms your secrets into an unreadable format using mathematical algorithms, requiring a specific key to decrypt and access the original data. For embedded systems, youβll typically encounter two approaches:
- Symmetric Encryption (AES or ChaCha20)
- Asymmetric Encryption (RSA or Elliptic Curve)
The main challenge with encryption is typically the encryption key management. This is where special and secure local devices could come in handy.
One-Time Programmable (OTP) Memory
OTP memory provides hardware-based secret device storage that can only be written once. Once programmed, the data becomes permanent and cannot be modified, making it ideal for storing device-unique secrets or root keys. This storage is tamper-resistant and can be read-protected from external access.
Here, the Raspberry Pi Pico 2 W (RP2350) has OTP memory, perfect for securely storing decryption keys for encrypted secrets.
ARM TrustZone
TrustZone is a type of Trusted Execution Environment (TEE) which is a system-wide security architecture that creates two parallel worlds on ARM processors:
- Secure World: The βsafe roomβ where sensitive code runs (i.e. secret handling).
- Normal (Non-secure) World: The regular area where the main code runs (i.e. reading sensors) and calls code within the secure world.
TrustZone-M provides hardware-enforced isolation through secure and non-secure states that extend across the processor, memory, and peripherals.
In other terms, itβs like having two separate computers on one chip where the βsecure computerβ can protect secrets while the βnormal computerβ handles everyday tasks, and they can communicate through carefully controlled secure gateways.
The Raspberry Pi Pico 2 W (RP2350) supports this!
Centralized Cloud-Based Services
For Internet of Things (IoT) devices with reliable network connectivity, centralized cloud-based secret management can be appropriate. Services like HashiCorp Vault or AWS Secrets Manager specialize in handling the full life cycle of sensitive data. The big consideration here is having a good connection to this centralized secrets management system.
Good! Now that some structural project things are in place, letβs move on and configure our device system clock.