Secret Management on iOS
One of the great unsolved questions in iOS development is, “How do I store secrets securely on the client?”
Baked into this question is the assumption that,
without taking proper measures,
those secrets will be leaked in one way or another —
either in source revision control (GitHub and the like),
from analysis tools running on .ipa
files distributed through the App Store,
or some other way.
Although credential security is often a second-tier concern at best, there’s a large and growing body of academic work that compels us, as developers, to take these threats seriously.
For instance, researchers at North Carolina State University found that thousands of API keys, multi-factor secrets, and other sensitive credentials are leaked every day on GitHub. (Meli et al., 2019) Another paper published in 2018 found SDK credential misuse in 68 out of a sample of 100 popular iOS apps. (Wen et al., 2018)
This week on NSHipster, we’re shamelessly invoking a meme from 2017 to disclose progressively more sophisticated countermeasures for keeping your secrets safe.
If your app is among the myriad others out there with an embedded Twitter access token, AWS Key ID, or any other manner of credential, read on and expand your mind.
🧠 Normal Brain Hard-Code Secrets in Source Code
Imagine an app that communicates with a web application
that requires an API key (a secret)
to be attached to outbound requests
for purposes of authentication.
This integration could be managed entirely by a third-party framework,
or it might be brokered with a URLSession
you set up in App
.
In either case,
there’s a question of how and where to store the required API key.
Let’s consider the consequences of embedding the secret in code as a string literal:
enum Secrets {
static let api Key = "6a0f0731d84afa4082031e3a72354991"
}
When you archive your app for distribution, all traces of this code appear to vanish. The act of compilation transforms human-readable text into machine-executable binary, leaving no trace of the secret… right?
Not quite.
Using a reverse-engineering tool
like Radare2,
that secret is plainly visible from inside the compiled executable.
You can prove this to yourself with your own project by
selecting “Generic iOS Device” in the active scheme in your own project,
creating an archive (Product > Archive),
and inspecting the resulting .xcarchive
bundle:
$ r2 ~/Developer/Xcode/Archives/….xcarchive/Products/Applications/Swordfish.app/Swordfish
[0x1000051fc]> iz
[Strings]
Num Paddr Vaddr Len Size Section Type String
000 0x00005fa0 0x100005fa0 30 31 (3.__TEXT.__cstring) ascii _Tt C9Swordfish14View Controller
001 0x00005fc7 0x100005fc7 13 14 (3.__TEXT.__cstring) ascii @32@0:8@16@24
002 0x00005fe0 0x100005fe0 36 37 (3.__TEXT.__cstring) ascii 6a0f0731d84afa4082031e3a72354991
…
While a typical user downloading your app from the App Store
would never even think to try opening the .ipa
on their device,
there are plenty of individuals and organizations whomst’d’ve
carved out a niche in the mobile app industry
analyzing app payloads for analytics and security purposes.
And even if the majority of these firms are above-board,
there’s likely to be a few bots out there
combing through apps just to pick out hard-coded credentials.
Much of that is just conjecture, but here’s what we know for sure:
If you hard-code secrets in source code, they live forever in source control — and forever’s a long time when it comes to keeping a secret. All it takes is one misconfiguration to your repo permissions or a data breach with your service provider for everything to be out in the open. And as one human working with many other humans, chances are good that someone, at some point, will eventually mess up.
Not to put too fine a point on it, but:
To quote Benjamin Franklin:
If you would keep your secret from an enemy, tell it not to a friend.
🧠 Big Brain Store Secrets in Xcode Configuration and Info.plist
In a previous article,
we described how you could use .xcconfig
files to
externalize configuration from code
according to 12-Factor App best practices.
When reified through Info.plist
,
these build settings can serve as makeshift
.env
files:
// Development.xcconfig
API_KEY = 6a0f0731d84afa4082031e3a72354991
// Release.xcconfig
API_KEY = d9b3c5d63229688e4ddbeff6e1a04a49
So long as these files are kept out of source control, this approach solves the problem of leaking credentials in code. And at first glance, it would appear to remediate our concern about leaking to static analysis:
If we fire up our l33t hax0r tools
and attenuate them to our executable as before
(izz
to list strings binary,
~6a0f07
to filter using the first few characters of our secret),
our search comes up empty:
$ r2 ~/Developer/Xcode/Archives/….xcarchive/Products/Applications/Swordfish.app/Swordfish
[0x100005040]> izz~6a0f
[0x100005040]>
But before you start celebrating, you might want to reacquaint yourself with the rest of the app payload:
$ tree …/Swordfish.app
├── Base.lproj
│ ├── Launch Screen.storyboardc
│ │ ├── 01J-lp-o VM-view-Ze5-6b-2t3.nib
│ │ ├── Info.plist
│ │ └── UIView Controller-01J-lp-o VM.nib
│ └── Main.storyboardc
│ ├── BYZ-38-t0r-view-8b C-Xf-vd C.nib
│ ├── Info.plist
│ └── UIView Controller-BYZ-38-t0r.nib
├── Info.plist
├── Pkg Info
├── Swordfish
├── _Code Signature
│ └── Code Resources
└── embedded.mobileprovision
That Info.plist
file we used to store our secrets?
Here it is, packaged right alongside our executable.
By some measures, this approach might be considered less secure than writing secrets in code, because the secret is accessible directly from the payload, without any fancy tooling.
$ plutil -p …/Swordfish.app/Info.plist
{
"API_KEY" => "6a0f0731d84afa4082031e3a72354991"
…
There doesn’t seem to be a way to configure the environment
when a user launches your app on their device.
And as we saw in the previous section,
using Info.plist
as a clearinghouse for secrets qua build settings
is also a no-go.
But maybe there’s another way we could capture our environment in code…
🧠 Cosmic Brain Obfuscate Secrets Using Code Generation
A while back, we wrote about GYB, a code generation tool used in the Swift standard library. Although that article focuses on eliminating boilerplate code, GYB’s metaprogramming capabilities go far beyond that.
For example, we can use GYB to pull environment variables into generated code:
$ API_KEY=6a0f0731d84afa4082031e3a72354991 \
gyb --line-directive '' <<"EOF"
%{ import os }%
let api Key = "${os.environ.get('API_KEY')}"
EOF
let api Key = "6a0f0731d84afa4082031e3a72354991"
Generating (and not committing) Swift files from GYB while pulling secrets from environment variables solves the problem of leaking credentials in source code, but it fails to guard against common static analysis tools. However, we can use a combination of Swift and Python code (via GYB) to obfuscate secrets in a way that’s more difficult to reverse-engineer.
For example, here’s an (admittedly crude) solution that implements an XOR cipher using a salt that’s generated randomly each time:
// Secrets.swift.gyb
%{
import os
def chunks(seq, size):
return (seq[i:(i + size)] for i in range(0, len(seq), size))
def encode(string, cipher):
bytes = string.encode("UTF-8")
return [ord(bytes[i]) ^ cipher[i % len(cipher)] for i in range(0, len(bytes))]
}%
enum Secrets {
private static let salt: [UInt8] = [
%{ salt = [ord(byte) for byte in os.urandom(64)] }%
% for chunk in chunks(salt, 8):
${"".join(["0x%02x, " % byte for byte in chunk])}
% end
]
static var api Key: String {
let encoded: [UInt8] = [
% for chunk in chunks(encode(os.environ.get('API_KEY'), salt), 8):
${"".join(["0x%02x, " % byte for byte in chunk])}
% end
]
return decode(encoded, cipher: salt)
}
…
}
Secrets are pulled from the environment and encoded by a Python function
before being included in the source code as [UInt8]
array literals.
Those encoded values are then run through an equivalent Swift function
to retrieve the original value
without exposing any secrets directly in the source.
The resulting code looks something like this:
// Secrets.swift
enum Secrets {
private static let salt: [UInt8] = [
0xa2, 0x00, 0xcf, …, 0x06, 0x84, 0x1c,
]
static var api Key: String {
let encoded: [UInt8] = [
0x94, 0x61, 0xff, … 0x15, 0x05, 0x59,
]
return decode(encoded, cipher: salt)
}
static func decode(_ encoded: [UInt8], cipher: [UInt8]) -> String {
String(decoding: encoded.enumerated().map { (offset, element) in
element ^ cipher[offset % cipher.count]
}, as: UTF8.self)
}
}
Secrets.api Key // "6a0f0731d84afa4082031e3a72354991"
While security through obscurity may be theoretically unsound, it can be an effective solution in practice. (Wang et al., 2018)
As the old saying goes:
You don’t have to outrun a bear to be safe. You just have to outrun the guy next to you.
🧠 Galaxy Brain Don’t Store Secrets On-Device
No matter how much we obfuscate a secret on the client, it’s only a matter of time before the secret gets out. Given enough time and sufficient motivation, an attacker will be able to reverse-engineer whatever you throw their way.
The only true way to keep secrets in mobile apps is to store them on the server.
Following this tack, our imagined plot shifts from an Oceans 11-style heist movie to a high-stakes Behind Enemy Lines escort mission movie. (Your mission: Transfer a payload from the server and store it in the Secure Enclave without an attacker compromising it.)
If you don’t have a server that you can trust, there are a few Apple services that can serve as a transport mechanism:
- On-Demand Resources: Download a data asset containing a plain-text secret.
- CloudKit Database: Set secrets in a private database in your CloudKit Dashboard. (Bonus: Subscribe to changes for automatic credential rolling functionality)
- Background Updates: Push secrets silently to clients as soon as they come online via APNS.
Then again, once your secret reaches the Secure Enclave, it’ll be sent right back out with the next outbound request for which it’s used. It’s not enough to get it right once; security is a practice.
Or put another way:
A secret in Secure Enclave is safe, but that is not what secrets are for.
🧠 Universe Brain Client Secrecy is Impossible
There’s no way to secure secrets stored on the client. Once someone can run your software on their own device, it’s game over.
And maintaining a secure, closed communications channel between client and server incurs an immense amount of operational complexity — assuming it’s possible in the first place.
Perhaps Julian Assange said it best:
The only way to keep a secret is to never have one.
Rather than looking at client secret management as a problem to be solved, we should see it instead as an anti-pattern to be avoided.
What is an API_KEY
other than an insecure, anonymous authentication mechanism, anyway?
It’s a blank check that anyone can cash,
a persistent liability the operational integrity of your business.
Any third-party SDK that’s configured with a client secret is insecure by design. If your app uses any SDKs that fits this description, you should see if it’s possible to move the integration to the server. Barring that, you should take time to understand the impact of a potential leak and consider whether you’re willing to accept that risk. If you deem the risk to be substantial, it wouldn’t be a bad idea to look into ways to obfuscate sensitive information to reduce the likelihood of exposure.
Restating our original question: “How do I store secrets securely on the client?”
Our answer: “Don’t (but if you must, obfuscation wouldn’t hurt).”