How to pass secret env vars in Xcode and CI
#iOS App Development
Apps always have secrets: API keys, shared secrets, passwords or some sort of auth tokens.
No one should put secrets into repository. Everyone knows that but keep doing that If you feel like it's you, take a look at the clean up guide
Actually it's Xcode's fault. Unless you are using Xcode Cloud, there is no convenient tool for managing secrets out-of-the box that would run smoothly both locally and on CI.
There used to be a tool called BuddyBuild that was injecting secrets, but it has gone and now only redirects to Xcode Cloud.
What's the problem
- CI services always allow to set environment variables.
- Local machine also has environment variables.
- Xcode has build phase scripts that are run before or after the app is built.
What could go wrong?
Build phase scripts are run s by Xcode as forked processes. They don't have any access to the scope of environment variables that are provided by CI.
It means that we need a workaround to pass the secrets. Here is how.
Overview
The solution consists of two parts:
- Pre-build scripts implemented as build phase scripts for Xcode
- CI-only script
Pre-build scripts
- Copy
env-example.xcconfig
toenv.xcconfig
if it doesn't exist (env_vars_init.sh) - Read from env.xcconfig and perform code generation with Sourcery template (env_vars_codegen.sh)
- Read from env.xcconfig and export env vars to use them in other build phase scripts (env_vars_export.sh)
CI scripts
- Write from CI environment variables to
env.xcconfig
Results
The result of all magic would be generated EnvironmentVars.generated.swift
data struct containing all the secrets. The app's code would rely on it.
Prepare env-example.xcconfig
The idea is to have a pair of .xcconfig
files. One for example purposes. Another to keep real secrets. Example would be under source control, while the one with real secrets would be in git ignore.
- Prepare dir for the stuff. For eg:
YourFancyProject/Configuration/EnvironmentVars
- Create
env-example.xcconfig
in Xcode: New file -> other -> Configuration settings file ->env-example.xcconfig
3. Add env vars examples to `env-example.xcconfig`
YOUR_SECRET = YOUR_SECRET_EXAMPLE
- Add
env-example.xcconfig
to git source control. - Add
env.xcconfig
to .gitignore
Initialization script for env.xcconfig
The Initialization script would copy env vars from env-example.xcconfig
to env.xcconfig
if env.xcconfig
does not exist.
- Add env vars init scipt: Project -> Target -> Build phases -> New Run script Phase
- Add the following script:
#!/bin/bash
# This script should be run as a phase script before building the project
#
# The script:
# 1. Copies .env-example.xcconfig to .env.xcconfig if .env.xcconfig does not exist
if [ $ACTION = "indexbuild" ]; then exit 0; fi
echo "EnvironmentVars init"
CONFIGURATION_PATH="${SRCROOT}/YourFancyProject/Configuration/EnvironmentVars"
# Path to the env.xcconfig file
xcconfig_file="${CONFIGURATION_PATH}/env.xcconfig"
if [ -f "$xcconfig_file" ]; then
echo "The file '$xcconfig_file' found."
else
echo "The file '$xcconfig_file' does not exist."
example_xcconfig_file="${CONFIGURATION_PATH}/env-example.xcconfig"
cp "$example_xcconfig_file" "$xcconfig_file"
echo "Copied from '$example_xcconfig_file' to '$xcconfig_file'"
fi
Setup Sourcery
- Install Sourcery how it's described here
- Add Sourcery template for environment vars in the EnvVar dir created above:
New file -> Other -> Empty and change the filename to EnvironmentVars.stencil
in the dir: /YourFancyProject/Configuration/EnvironmentVars
3. Add the following template code:
//swiftlint:disable all
struct EnvironmentVars {
/// Generated using 'EnvironmentVars.stencil' template
static let yourSecret = "{{ argument.YOUR_SECRET }}"
}
- Add
EnvironmentVars.generated.swift
to git ignore. It will contain secrets after all magic is done
Code generation script
The script would read the env vars from env.xcconfig
file, prepare the list of arguments in a proper format and run the Sourcery code generation with the EnvironmentVars.stencil
template.
- Add code gen env vars script: Project -> Target -> Build phases -> New Run script Phase:
# This script should be run as a phase script before building the project
#
# The script:
# 1. Reads env vars from .env.xcconfig
# 2. Performs EnvironmentVars code generation using Sourcery and EnvironmentVars.stencil template
if [ $ACTION = "indexbuild" ]; then exit 0; fi
echo "EnvironmentVars CodeGen"
CONFIGURATION_PATH="${SRCROOT}/YourFancyProject/Configuration/EnvironmentVars"
TEMPLATE_PATH="${CONFIGURATION_PATH}/EnvironmentVars.stencil"
# Path to the env.xcconfig file
xcconfig_file="${CONFIGURATION_PATH}/env.xcconfig"
if ! [ -f "$xcconfig_file" ]; then
echo "The file '$xcconfig_file' does not exist. Run 'env_vars_init.sh' first."
exit 1;
fi
echo "Performing EnvironmentVars CodeGen."
# Declare an empty string to hold the env variables in comma separated format
env_vars=""
# Read each line from the xcconfig file
while IFS= read -r line; do
# Ignore comments and empty lines
if [[ "$line" =~ ^\s*# ]] || [[ -z "$line" ]]; then
continue
fi
# Extract the variable name and value from the line
var_name="$(echo "$line" | cut -d'=' -f1 | sed 's/ //g')"
var_value="$(echo "$line" | cut -d'=' -f2- | sed 's/^ *//;s/ *$//')"
# Append the variable to the env_vars string
env_vars+="$var_name=$var_value,"
done < "$xcconfig_file"
# Remove the trailing comma
env_vars="${env_vars%,}"
# Run Sourcery Codegen
$PODS_ROOT/Sourcery/bin/sourcery --templates "${TEMPLATE_PATH}" --sources "${CONFIGURATION_PATH}" --output "${CONFIGURATION_PATH}" --args "$env_vars"
This script is legit for Sourcery installation done via CocoaPods. If Sourcery is installed in another way the Sourcery call in the end should point the correct binary location.
Env vars export script
If your project has some build phase script that requires your secret env vars. You can use env vars export script to obtain them. It would read the secrets from env.xcconfig
and export them as environment vars.
The common use case is to pass auth token to fetch GraphQL schema in a build phase script.
Let's do it
- Create a dir for the script
/YourFancyProject/Scripts
- Create script: New file -> Other -> Shell Script ->
env_vars_export.sh
- Add the following code to the script file
#!/bin/bash
# This script could be run as a phase script if env vars needed
#
# The script:
# 1. Reads env vars from .env.xcconfig
# 2. Exports env vars
if [ $ACTION = "indexbuild" ]; then exit 0; fi
echo "EnvironmentVars Export"
CONFIGURATION_PATH="${SRCROOT}/YourFancyProject/Configuration/EnvironmentVars"
# Path to the env.xcconfig file
xcconfig_file="${CONFIGURATION_PATH}/env.xcconfig"
if ! [ -f "$xcconfig_file" ]; then
echo "The file '$xcconfig_file' does not exist. Run 'env_vars_init.sh' first."
exit 1;
fi
echo "Performing EnvironmentVars Export..."
# Read each line from the xcconfig file
while IFS= read -r line; do
# Ignore comments and empty lines
if [[ "$line" =~ ^\s*# ]] || [[ -z "$line" ]]; then
continue
fi
# Extract the variable name and value from the line
var_name="$(echo "$line" | cut -d'=' -f1 | sed 's/ //g')"
var_value="$(echo "$line" | cut -d'=' -f2- | sed 's/^ *//;s/ *$//')"
export "$var_name"="$var_value"
done < "$xcconfig_file"
echo "EnvironmentVars Exported."
Usage example:
#!/bin/bash
# Don't run this during index builds
source "$SRCROOT/YourFancyProject/Scripts/env_vars_export.sh"
echo "${YOUR_SECRET}"
Write Env Vars to env.xcconfig on CI
The final part of the whole thing is to write CI environment vars to env.xcconfig
file
- Create a dir for the script ``/YourFancyProject/Scripts`
- Create script: New file -> Other -> Shell Script ->
write_env_vars_to_env_config.sh
- Add the following code to the script file:
#!/bin/bash
# This script should be run on CI from project dir as a step before building the project
# First, Make sure that all env vars from .env-example.xcconfig are set on CI
#
# The script:
# 1. Reads all env vars defined in env-example.xcconfig
# 2. Writes env var values to env.xcconfig
config_path="./YourFancyProject/Configuration/EnvironmentVars"
# Path to the env.xcconfig file
xcconfig_file="${config_path}/env.xcconfig"
# Path to the env.xcconfig file
example_xcconfig_file="${config_path}/env-example.xcconfig"
# Open the source and destination files for reading and writing
exec 3<$example_xcconfig_file
exec 4>$xcconfig_file
echo "Writing env vars to '$xcconfig_file'..."
# Read the source file line by line and write to the destination file
while read -u 3 line; do
# Extract the variable name and value from the line
var_name="$(echo "$line" | cut -d'=' -f1 | sed 's/ //g')"
default_var_value="$(echo "$line" | cut -d'=' -f2- | sed 's/^ *//;s/ *$//')"
# Extract the variable value from env vars
var_value="${!var_name}"
if [[ -n "$var_value" ]]; then
echo "$var_name = $var_value" >&4
echo "Wrote '$var_name' from env vars."
else
echo "$var_name = $default_var_value" >&4
echo "Wrote default value for '$var_name'."
fi
done
# Close the files
exec 3<&-
exec 4>&-
echo "Wrote env vars to '$xcconfig_file' successfully."
- Add
write_env_vars_to_env_config.sh
script as a command to one of the job steps on CI.
Here is a CircleCI example, but you can do the same thing on any another CI service:
version: 2.1
commands:
write_env_vars:
steps:
- run:
name: Write Env Vars to env.xcconfig
command: Scripts/write_env_vars_to_env_config.sh
- Add secrets to the project's environment variables:
Usage
How to use after setup
For local development purposes, developer would need to
- Build the project first. It would init
env.xcconfig
with keys and values fromenv-example.xcconfig
- Make sure that
env.xcconfig
is added to git ignore. - Write real secrets to
env.xcconfig
- Make sure that
EnvironmentVars.generated.swift
is added to git ignore - Build the project
- Use your secrets in the project from
EnvironmentVars.generated.swift
file'sEnvironmentVars
struct
On CI
- Check that all secrets are added as the project's environment variables:
How to add new secret env var:
- Add it as an example to
env-example.xcconfig
:
YOUR_SECRET = YOUR_SECRET_EXAMPLE
NEW_SECRET = NEW_SERCRET_EXAMPLE_VALUE
- Add real secret value to
env-example.xcconfig
YOUR_SECRET = YOUR_SECRET_REAL_SECRET_VALUE
NEW_SECRET = NEW_SERCRET_REAL_SECRET_VALUE
- Update
.stencil
template
//swiftlint:disable all
struct EnvironmentVars {
/// Generated using 'EnvironmentVars.stencil' template
static let yourSecret = "{{ argument.YOUR_SECRET }}"
static let yourNewSecret = "{{ argument.NEW_SECRET }}"
}
- Add new
NEW_SECRET
env var on CI (CircleCI) example
Q&A
Why do we need Sourcery?
Sourcery is not a mandatory. Once we have secrets in *.xcconfig
file it can be accessed as an Xcode build configuration throught a .plist
variable.
But.
You are likely already using Xcode build configuration for other purposes.
For eg, to configure the project with environment-dependent constants, like API-base url, or other settings.
I believe that secrets deserve a special place:
- We want to keep secrets out of source control.
- We want to keep other configuration variables under source control.
So we don't better mess them all together.
Another reason to use Sourcery is because it's much more convenient to see and use secret vars as EnvironmentVars
typed constant, other than to get them from Bundle
info dictionary in the runtime.
What can be improved?
We should be aware that Strings are still not secure with that setup.
When we build and ship the app with secrets defined as string literals, they can be relatively easily reverse-engineered from the app's IPA package.
If we want to make it more secure we would need to think about applying the obfuscation of EnvironmentVars properties definitions.
Luckily, when we have all the workflow setup with an EnvironmentVars data struct as an output, the obfuscation step, in the end, is rather straightforward.
It can be done with one of the libs:
- Swift-String-Obfuscator
- Obfuscator with a tutorial right here
- Swift Confidential
That's why EnvironmentVars data struct still has some room for improvement. If we want to make it more secure we would need to think about applying the obfuscation of EnvironmentVars properties definitions.
Summary
As the result we get a pretty much reliable solution for managing secrets.
Github Link
Code for the scripts above can be found here:
Scripts for managing env vars in Xcode and CI · GitHub
Comments