Mobile secrets
Handle mobile secrets the secure way with ease
Working with this GEM is described in detail here: https://medium.com/@cyrilcermak/mobile-secrets-8458ceaf4c16
MobileSecrets comes with a simple YAML configuration. The configuration can be generated by executing mobile-secrets --create-template.
Configuration
MobileSecrets:
# hashKey: Key used to obfuscate/encrypt secret values.
# Must be exactly 32 characters when using alg: "AES-GCM" or when encrypting files.
hashKey: "REPLACE_THIS_32_CHAR_HASH_KEY___"
# shouldIncludePassword: By default the password is saved in the code as a series of bytes, however it can also
# be fetched from your API, saved in keychain and passed to the Secrets for improving the security.
shouldIncludePassword: true
# language: Swift is currently only supported language, Kotlin is coming soon.
language: "Swift"
# alg: Algorithm used to obfuscate secret values at build time.
# "XOR" — XOR cipher (default). Fast, no platform version requirements.
# "AES-GCM" — AES-256-GCM authenticated encryption via CryptoKit.
# Requires iOS 13+ / macOS 10.15+ and a 32-character hashKey.
alg: "XOR"
# Key-value dictionary for secrets. The key is then referenced in the code to get the secret.
secrets:
googleMaps: "YOUR_GOOGLE_MAPS_KEY"
firebase: "YOUR_FIREBASE_KEY"
amazon: "YOUR_AMAZON_KEY"
# Optional — remove the files section if you do not want to encrypt files.
# File encryption always uses AES-256-CBC and requires a 32-character hashKey.
files:
- tmp.txt
- Info.plistChoosing an algorithm
XOR (default) |
AES-GCM |
|
|---|---|---|
| Security | Obfuscation only | Authenticated encryption (strong) |
| iOS requirement | Any | iOS 13+ / macOS 10.15+ |
| hashKey length | Any | Exactly 32 characters |
| Swift framework | Foundation | CryptoKit |
| Per-secret IV | No | Derived deterministically (HMAC-SHA256) |
Tip: Use
AES-GCMfor stronger protection at build time. UseXORwhen you need to support iOS 12 or earlier, or when you supply the password at runtime from Keychain / a remote API.
Usage in iOS
The generated secrets.swift is dropped into your Xcode project and used as follows:
// Retrieve a secret (password embedded in binary)
let googleMapsKey = Secrets.standard.string(forKey: "googleMaps")
// Retrieve a secret with a runtime password (shouldIncludePassword: false)
let googleMapsKey = Secrets.standard.string(forKey: "googleMaps", password: myRuntimeKey)
// Decrypt bundled .enc files (run once — decrypted file persists on disk)
try? Secrets.standard.decryptFiles()XOR-generated secrets.swift (example)
//
// Autogenerated file by Mobile Secrets
//
import Foundation
// swiftlint:disable all
class Secrets {
static let standard = Secrets()
private let bytes: [[UInt8]] = [[75, 111, 107, 111, 66, 101, 108, 108, 111, 75, 111, 107, 111,
75, 111, 107, 111, 66, 101, 108, 108, 111, 75, 111, 107, 111,
75, 111, 107, 111, 66, 101],
[103, 111, 111, 103, 108, 101, 77, 97, 112, 115],
[122, 93, 88, 94, 112, 86, 93, 94, 92]]
private init() {}
func string(forKey key: String, password: String? = nil) -> String? {
let pwdBytes = password == nil ? bytes[0] : password?.map({ c in c.asciiValue ?? 0 })
guard let index = bytes.firstIndex(where: { String(data: Data($0), encoding: .utf8) == key }),
let pwd = pwdBytes,
let value = decrypt(bytes[index + 1], password: pwd) else { return nil }
return String(data: Data(value), encoding: .utf8)
}
// XOR-based deobfuscation. The key cycles over the password bytes.
private func decrypt(_ input: [UInt8], password: [UInt8]) -> [UInt8]? {
guard !password.isEmpty else { return nil }
var output = [UInt8]()
for byte in input.enumerated() {
output.append(byte.element ^ password[byte.offset % password.count])
}
return output
}
}AES-GCM-generated secrets.swift (example)
Each secret value is encrypted with AES-256-GCM. The IV is derived deterministically from HMAC-SHA256(hashKey, "secretName:value"), so the generated byte arrays are stable across exports for the same input — giving you reproducible builds and clean git diffs. Rotating a secret value automatically derives a new IV, preventing nonce reuse.
//
// Autogenerated file by Mobile Secrets
//
import CryptoKit
import Foundation
// swiftlint:disable all
@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
class Secrets {
static let standard = Secrets()
private let bytes: [[UInt8]] = [[75, 111, 107, 111, 66, 101, 108, 108, 111, 75, 111, 107, 111,
75, 111, 107, 111, 66, 101, 108, 108, 111, 75, 111, 107, 111,
75, 111, 107, 111, 66, 101],
[103, 111, 111, 103, 108, 101, 77, 97, 112, 115],
// IV(12) + AuthTag(16) + Ciphertext(N)
[34, 201, 88, 12, 77, 203, 11, 99, 56, 210, 4, 33,
145, 67, 23, 189, 200, 44, 90, 12, 78, 56, 33, 199, 100, 55, 21, 9,
88, 201, 65, 77, 12, 90, 55, 23, 200]]
private init() {}
func string(forKey key: String, password: String? = nil) -> String? {
let pwdBytes = password == nil ? bytes[0] : password?.map({ c in c.asciiValue ?? 0 })
guard let index = bytes.firstIndex(where: { String(data: Data($0), encoding: .utf8) == key }),
let pwd = pwdBytes,
let value = decrypt(bytes[index + 1], password: pwd) else { return nil }
return String(data: Data(value), encoding: .utf8)
}
// AES-256-GCM decryption. Input layout: IV(12) + AuthTag(16) + Ciphertext(N)
private func decrypt(_ input: [UInt8], password: [UInt8]) -> [UInt8]? {
guard password.count == 32 else { return nil }
let ivSize = 12; let tagSize = 16
guard input.count > ivSize + tagSize else { return nil }
do {
let key = SymmetricKey(data: Data(password))
let nonce = try AES.GCM.Nonce(data: Data(input[0..<ivSize]))
let tag = Data(input[ivSize..<(ivSize + tagSize)])
let ciphertext = Data(input[(ivSize + tagSize)...])
let sealedBox = try AES.GCM.SealedBox(nonce: nonce, ciphertext: ciphertext, tag: tag)
return [UInt8](try AES.GCM.open(sealedBox, using: key))
} catch { return nil }
}
}CLI commands
| Command | Description |
|---|---|
--init-gpg PATH |
Initialise a GPG keyring in the given directory |
--create-template |
Copy example.yml to ./MobileSecrets.yml
|
--import SECRETS_PATH [GPG_FILE] |
GPG-encrypt the YAML config (secrets.gpg by default) |
--export PATH [ENCRYPTED_FILE_PATH] |
Decrypt GPG file and write secrets.swift to PATH
|
--encrypt-file FILE PASSWORD |
AES-256-CBC encrypt a single file → FILE.enc
|
--empty PATH |
Write an empty Secrets Swift stub to PATH/secrets.swift
|
--edit GPG_FILE |
Open the encrypted config in your editor via dotgpg edit
|
--usage |
Print numbered workflow instructions |
Workflow
- Create a GPG keyring in the current directory:
mobile-secrets --init-gpg "." - Generate the config template:
mobile-secrets --create-template
- Edit
MobileSecrets.yml— set yourhashKey, choosealg, add your secrets. - Encrypt the config into
secrets.gpg:mobile-secrets --import ./MobileSecrets.yml
- Export
secrets.swiftto your project:mobile-secrets --export ./MyApp/Sources/
- Add
secrets.swiftto your Xcode project target. -
Delete
MobileSecrets.yml— your secrets are now safely stored insecrets.gpgonly.
To update secrets later:
mobile-secrets --edit secrets.gpg # opens editor via dotgpg
mobile-secrets --export ./MyApp/Sources/Running the tests
# Full test suite (unit + E2E Swift compilation tests)
rake test
# Single test file
ruby -Ilib -Itest test/test_secrets_handler.rb
# Single test by name
ruby -Ilib -Itest test/test_e2e.rb -n test_aes_gcm_with_embedded_password_round_trips_all_secrets