Hiding your Mobile Secrets
| Ellen ShapiroWhen you open-source a project, you want to avoid leaking API keys and other secret sauce to the public. Best case scenario, someone will use your API keys to create dozens of apps and you will get bombarded with emails about them. Worst case scenario, they can rack up an enormous bill or steal all your data.
I've been working on a Kotlin/Native framework (which allows a common shared codebase between iOS and Android) and its two companion apps as an experiment. As with most experiments I do, I wanted to keep it open-source, but I also wanted to be able to put crash reporting with Crashlytics in there so I could see quickly when things broke.
There is not a Kotlin/Native Crashlytics SDK, so I was going to need to install the iOS and Android SDKs at app level. I decided to use this as an excuse to research how to keep secrets on apps for each platform.
In the end, with the use of a .secrets
folder and some well placed environment
variables on our Continuous Integration server, I was able to get the app
building and running with Crashlytics on both platforms, in ways which would be
reproducible even if we weren't using Kotlin/Native in the future.
If you only care about one platform, you can jump to reading about either Android or iOS, but the overall idea is to automate much of the following process:
- Pull anything secret for a particular platform out of your public-facing items and into a file.
- Stick that file into a git-ignored
.secrets
folder. - When running locally, load the ignored file.
- When running on CI, load values from environment variables.
- Take the secure values you retrieved from wherever, and put them into the file which needs those values.
- Finish your build.
git reset
the file which should not have secret values committed.
Android
We'll start with the easier of the two platforms - Android.
Android's easier because it already uses the Gradle build system, which makes creating new things to plug into it relatively easy, once you figure out how to hook into the system.
When you go create an Android application through Firebase, you'll be asked to
download a google-services.json
file that has all the information Firebase
needs to spin up its SDK. This needs to be placed in the root of your
repository.
Unfortunately, this file has a number of sensitive API keys in it, which should not be committed to a public repository. So how do you take them out?
I made an android_secrets.properties
file and stuck it in the git-ignored
.secrets
folder. A .properties
file is a plaintext-ish file which a format
which looks like this:
YOUR_KEY_NAME=YourValue
YourValue
is stored as a string, so quotation marks are not necessary (and
often are not actually wanted).
I then took all the values out of the google-services.json
file which I did
not want to be public, and replaced them with the string
REPLACE_AT_BUILD_TIME
, so that the JSON looked something like:
{
"project_info" : {
"project_number": "REPLACE_AT_BUILD_TIME"
},
"client": [
{
"api_key" : [
"current_key": "REPLACE_AT_BUILD_TIME"
]
}
]
}
This makes it super obvious if what you're doing isn't actually working at build time - You'll get a crash if the values provided to the Firebase SDK are not in the proper format. This can also be committed to version control without worry.
I took the sensitive keys I'd removed and placed them into the .properties
file I referred to earlier, which then looked something like this:
FIR_PROJECT_NUMBER=FirstSecureValue
FIR_CURRENT_KEY=SecondSecureValue
I decided to make a gradle Task to replace google services - that way I could set it up as a dependency for another task I knew would always run.
In the build.gradle
at my Android project level, I added a new task below my
android
and dependencies
closures:
task replaceGoogleServices {
}
To test that it was working, I gave it a very simple doLast
block, which is
what's executed as the last piece of the task you're setting up:
task replaceGoogleServices {
doLast {
println "REPLACE GOOGLE!"
}
}
and then below the task's declaration, I set it up as a dependency of preBuild
(which is run with pretty much any Android gradle command:
preBuild.dependsOn replaceGoogleServices
When that ran, I could see the "REPLACE GOOGLE!"
print out in the console, and
I could confirm it worked. Next, I knew I wanted to add some additional methods,
so I started like by trying to define some initial methods:
task replaceGoogleServices {
def loadSecrets() {
def secrets = loadSecretsFromProperties()
if (secrets == null) {
secrets = loadSecretsFromEnv()
}
assert(secrets != null)
return secrets
}
def loadSecretsFromProperties() {
// TODO
}
def loadSecretsFromEnv() {
// TODO
}
doLast {
println "REPLACE GOOGLE!"
}
}
The problem is that within a Gradle Task, you can't use the normal def
syntax
used to define a method. You must declare methods like so:
task replaceGoogleServices {
ext.loadSecrets = {
def secrets = loadSecretsFromProperties()
if (secrets == null) {
secrets = loadSecretsFromEnv()
}
assert(secrets != null)
return secrets
}
ext.loadSecretsFromProperties = {
// TODO
}
ext.loadSecretsFromEnv = {
// TODO
}
doLast {
println "REPLACE GOOGLE!"
}
}
You can still call your methods in exactly the same way as you would with
def
defined methods, you just have to set them up as closures.
Then, I fleshed out the method to load the secrets from a file:
ext.loadSecretsFromProperties = {
def secretsPropertiesFile = rootProject.file(".secrets/android_secrets.properties")
def secretsProperties = new Properties()
try {
secretsProperties.load(new FileInputStream(secretsPropertiesFile))
println "Loaded secrets from local file..."
} catch (FileNotFoundException e) {
// File was not found, try to load from env.
return null
}
return secretsProperties
}
If that failed, I'd try to load the same keys from the environment:
ext.getEnvironmentValue = { key ->
return System.getenv().get(key, null)
}
ext.loadSecretsFromEnv = {
def url = getEnvironmentValue("FIR_PROJECT_NUMBER")
if (url == null) {
return null
}
println("Loading secrets from env...")
def secretsProperties = new Properties()
secretsProperties.put("FIR_PROJECT_NUMBER", getEnvironmentValue("FIR_PROJECT_NUMBER"))
secretsProperties.put("FIR_CURRENT_KEY", getEnvironmentValue("FIR_CURRENT_KEY"))
return secretsProperties
}
Once I had that down, I was able to go into the doLast
method and use it (and
Groovy's slightly insane JSON parsing library) to get the values from the
secrets.properties
file into the JSON:
doLast {
def secrets = loadSecrets()
assert (secrets != null)
def servicesJSONFile = rootProject.file('android/google-services.json')
def parsedJSON = new JsonSlurper().parseText(servicesJSONFile.text)
def builder = new JsonBuilder(parsedJSON)
def projectNumber = secrets.getProperty("FIR_PROJECT_NUMBER")
assert(projectNumber != null)
builder.content.project_info.project_number = projectNumber
def currentKey = secrets.getProperty("FIR_CURRENT_KEY")
assert(currentKey != null)
builder.content.client[0].api_key[0].current_key = currentKey
// [You get the idea, more code omitted for brevity]
servicesJSONFile.write(builder.toPrettyString())
}
After this ran, the JSON was updated to look something like:
{
"project_info" : {
"project_number": "FirstSecureValue"
},
"client": [
{
"api_key" : [
"current_key": "SecondSecureValue"
]
}
]
}
Huzzah! The values were being replaced automatically. Now, all that was left was
automatically resetting google-services.json
so I didn't accidentally commit
those values I'd gone through all this trouble to secure.
Figuring out how to get something to execute after a task is completed in gradle is a little bit harder than it seems on its face. However, I did find a workaround.
After some research I realized that the gradle task graph has an afterTask
closure, as well as a list of all the tasks it's performing.
Below the end of my resetGoogleServices
task, I added something to look for
the last task so I could execute something after the last task had completed:
gradle.taskGraph.afterTask { Task task ->
if (task == gradle.taskGraph.allTasks.last()) {
println "DO THIS LAST!"
}
}
Once I verified that was executing where it needed to, after some time trying to
use another task
, I realized things would be much easier if I just used a
def
method, which had caused me so much problem in working with a Task before.
Once I realized that, it was simple to access the file and then reset it with a method, and then call that method when the task graph completed its last task:
def resetGoogleServicesFile() {
println "Resetting services JSON"
def servicesJSONFile = rootProject.file('android/google-services.json')
"git checkout HEAD -- ${servicesJSONFile.path}".execute()
}
gradle.taskGraph.afterTask { Task task ->
if (task == gradle.taskGraph.allTasks.last()) {
resetGoogleServicesFile()
}
}
The full version of the build.gradle
file I landed on can be found in our
[DoorbellBot-Native]
repo
here.
iOS
As with anything Apple makes, iOS's build system is a bit opaque. It's also somewhat difficult to hook into without shell scripting. I was interested in a solution which did not involve shell scripting, for the very technical reason that I am terrible at it, and screw it up constantly.
Swift provides an interesting twist to this, particularly with the use of the Swift Package Manager, often referred to as SPM.
Swift files can be turned into executable scripts, but you also can create small command line applications using SPM which allow you to take advantage of imported frameworks. SPM is built into the version of Xcode that will be out this fall, but that wasn't even announced when I started working on this, so I needed to do things in a bit more of a complex fashion.
I'd built some Swift scripting tooling using John Sundell's Marathon layer on top of SPM. I really loved Marathon's ease of setup, but it's designed more for things that don't need to run on every build of your app. It takes a second to spin everything it needs up, so if you're running it on every build, it can cause the build to stutter slightly.
In my previous use of Marathon, I'd wound up hacking together some shell scripts to determine if I needed to run Marathon to prevent the hiccup, but that seemed to defeat the point of trying to run scripts in Swift in the first place.
I decided to try to build my own command line application with the Swift Package Manager directly to see if I could remove the overhead for something I wanted to run every time I built my app.
Ironically, John Sundell's own work on Marathon is part of what got me up and running with this quickly: John's got a great guide to setting up a command line application which walked through some of the process of Marathon's setup.
I'll leave that link as the definitive guide to setting up a command line tool, as it's very thorough. I wound up also using a couple of small libraries John put together for scripting, Files, to make dealing with the File System simpler, and ShellOut, to make running Bash commands simpler.
There was a small twist in the data formats this time as well: Instead of Google handing me JSON, then my script loading from a platform-specific data storage type, it was the reverse.
Google hands you a GoogleService-Info.plist
file where all of your keys are
stored. I went through a similar process of taking out the sensitive keys and
replacing them with garbage information such as REPLACE_AT_BUILD_TIME
as I did
with Android, but this time I put the sensitive keys into a JSON file which was
stored in .secrets
, since JSON is super-easy to parse with Swift.
First, in the main bit of the script, I'd use Files
to grab access to the
folder represented by $SRCROOT
in the environment - the source root of the iOS
app (checking to make sure it was finding the right folder), and pass it into a
function I'd made to update the Plist:
let rootPath = try shellOut(to: ShellOutCommand(string: "echo $SRCROOT"))
guard rootPath.hasSuffix("iOS") else {
throw FileError.cantAccessSRCROOT
}
let rootFolder = try Folder(path: root)
try FirebasePlistUpdater.run(sourceRoot: rootFolder)
Similarly to the Android script, I'd check to see if there was a local file, and if there wasn't I'd try to load secrets from the environment. Here's what that function looks like:
enum Error: Swift.Error {
case couldNotAccessGitRoot
case noSecretsFileOrSecrets(String)
}
static func run(sourceRoot: Folder) throws {
guard let gitRoot = sourceRoot.parent else {
throw Error.couldNotAccessGitRoot
}
let secrets: [String: String]
if self.exportJSONExists(gitRoot: gitRoot) {
print("Secrets file exists locally, loading from that...")
secrets = try self.loadSecretsFromJSON(gitRoot: gitRoot)
} else {
print("No local secrets file, attempting to load from environment variables...")
secrets = self.loadSecretsFromEnvironment()
guard !secrets.isEmpty else {
throw Error.noSecretsFileOrSecrets("You don't have a secrets file or secrets set up in the environment - this ain't gonna work. Please make sure to set up secrets on CI." )
}
}
try self.updateFirebasePlistValuesCommands(sourceRoot: sourceRoot, secrets: secrets)
}
One thing I particularly like about doing this in Swift is being able to add
customizable errors. I wound up using a lot of print
statements as well so I
could see what was happening at certain levels on the CI server as well.
But when something went wrong, it's was really helpful to have a specifically typed error which eventually got printed out rather than a more generic exit code. It made debugging the whole thing much faster.
The Files
library made checking if a file exists very simple:
private static func exportJSONExists(gitRoot: Folder) -> Bool {
do {
let secretsFolder = try gitRoot.subfolder(named: secretsFolderName)
return secretsFolder.containsFile(named: secretsFileName)
} catch {
debugPrint("Error checking if export script exists: \(error)")
return false
}
}
I didn't want this function to throw
, but to simply return false
if there
was an issue, since that issue could be handled the same way as a nonexistent
file would be handled: By trying to load the secrets from the Environment.
I was able to use Files
to cut down on code loading from JSON as well:
private static func loadSecretsFromJSON(gitRoot: Folder) throws -> [String: String] {
let secretsFolder = try gitRoot.subfolder(named: secretsFolderName)
let iosFile = try secretsFolder.file(named: secretsFileName)
return try JSONLoader.loadStringJSON(from: iosFile)
}
If for some reason either the JSON file didn't exist or couldn't be loaded, I'd try loading the secrets from the environment. Fortunately Swift has an extremely simple way to do this:
private static func loadSecretsFromEnvironment() -> [String: String] {
let envDict = ProcessInfo.processInfo.environment
return envDict.filter { key, _ in
return key.hasPrefix("FIR_")
}
}
Then, once I had the secrets either from JSON or from the Environment, I made a
wrapper for an Apple-provided tool called
Plistbuddy
(Note that link is to
another site because Apple doesn't actually have any documentation for
Plistbuddy beyond its man
page) to replace replace data within the plist
I
got from Google.
With that wrapper, I was able to quickly go through and update values for
different secrets in the plist
using the data I'd loaded from either the JSON
file or Environment variables:
private static func updateFirebasePlistValuesCommands(sourceRoot: Folder,
secrets: [String: String]) throws {
let iOSAppFolder = try sourceRoot.subfolder(named: "DoorbellBotNative")
let plistFile = try iOSAppFolder.file(named: "GoogleService-Info.plist")
let keysToUpdate = [
"AD_UNIT_ID_FOR_BANNER_TEST",
"API_KEY",
"CLIENT_ID",
"DATABASE_URL",
"GCM_SENDER_ID",
"GOOGLE_APP_ID",
"PROJECT_ID",
"REVERSED_CLIENT_ID",
"STORAGE_BUCKET"
]
keysToUpdate.forEach { key in
let secretKey = "FIR_\(key)"
guard let secret = secrets[secretKey] else {
print("No secret found for \(secretKey)")
return
}
Plister.setValue(secret, for: key, in: plistFile)
}
Plister.save(file: plistFile)
}
Unlike the Gradle code being able to handle everything for the Android app, I did have to add a bash script with one line at the very end of my build process for the iOS app:
git checkout HEAD -- "$SRCROOT/DoorbellBotNative/GoogleService-Info.plist"
Unfortunately, there's no real way to do this as part of the Swift Script
without writing a second script - it must wait to be run until after the app has
been built with the plist
that has all the secrets in it, and you can't tell
the rest of the build steps to proceed until after your Swift script exits.
However, I'd now managed to get the amount of code written in untyped Bash down to a minimum, which was one of the primary goals of this exercise, so I figured that'd do for now.
If you're interested in seeing the whole mess, you can see the
Scripty
project
in the DoorbellBot-Native
repo, and the calls to it in the build phases of the
main xcodeproj
file.
Conclusion
It takes a little bit of banging your head on the desk to get tools doing exactly what you want them to do, but once a task like this is automated, you can share that automation throughout the projects which you work on.
Particularly when you use tools like the Swift Package Manager and Gradle that encourage the sharing of code and techniques, building something reusable across many projects can be both fun and super-useful.
And even if you're trying to build something cross-platform, you can still spend plenty of time noodling on something applicable to a single platform, and which can help you when building natively.