Today I want to share a helper class I worked on a while back. The requirement was that some (large) piece of data needed to be stored on the device, it should be stored encrypted. File protection complete was deemed not enough.
The idea was that a piece of data needed to be stored on disk, with a key stored in the keychain, in the secure enclave if available.
Just a few lines, but there’s actually a lot going on. The code created was inspired by various bits and pieces online and it took a bit of trial and error to get right.
First things first. We need to determine if a Secure Enclave is available and functional.
import Foundation
import os.log
enum CryptoError: Error {
case keyCreationFailed
}
class Crypto {
static var shared: Crypto {
return self.sharedInstance
}
private static let sharedInstance = Crypto()
private let keyName = "disk_storage_key"
private let hasSecurityEnclave: Bool
private let algorithm: SecKeyAlgorithm
private let keySize: Int
private let keyType: CFString
init() {
var attributes = [String: Any]()
attributes[kSecAttrKeyType as String] = kSecAttrKeyTypeEC
attributes[kSecAttrKeySizeInBits as String] = 256
attributes[kSecAttrTokenID as String] = kSecAttrTokenIDSecureEnclave
var error: Unmanaged<CFError>?
let randomKey = SecKeyCreateRandomKey(attributes as CFDictionary, &error)
if randomKey != nil {
self.hasSecurityEnclave = true
self.keySize = 256
self.keyType = kSecAttrKeyTypeEC
self.algorithm = .eciesEncryptionCofactorVariableIVX963SHA256AESGCM
} else {
self.hasSecurityEnclave = false
self.keySize = 4096
self.keyType = kSecAttrKeyTypeRSA
self.algorithm = .rsaEncryptionOAEPSHA512AESGCM
}
}
}
That actually quite a lot of code already isn’t it. But if you follow along in the above code, it’s not that complicated. We just try to create a random key that requires the Secure Enclave. To my knowledge this generates a key pair, but does not store anything in the keychain or the Secure Enclave. Based on the result, a couple properties are stored on the Crypto class.
Generating a new key pair
Next up is generating a key pair and actually hanging on to it.
private func makeAndStoreKey(name: String) throws -> SecKey {
guard let access =
SecAccessControlCreateWithFlags(
kCFAllocatorDefault,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
self.hasSecurityEnclave ? SecAccessControlCreateFlags.privateKeyUsage : [],
nil) else {
throw CryptoError.keyCreationFailed
}
var attributes = [String: Any]()
attributes[kSecAttrKeyType as String] = self.keyType
attributes[kSecAttrKeySizeInBits as String] = self.keySize
#if targetEnvironment(simulator)
print("SIMULATOR does not support secure enclave.")
#else
if self.hasSecurityEnclave {
attributes[kSecAttrTokenID as String] = kSecAttrTokenIDSecureEnclave
}
#endif
let tag = name
attributes[kSecPrivateKeyAttrs as String] = [
kSecAttrIsPermanent as String: true,
kSecAttrApplicationTag as String: tag,
kSecAttrAccessControl as String: access
]
var error: Unmanaged<CFError>?
let privateKey = SecKeyCreateRandomKey(attributes as CFDictionary, &error)
if let error = error {
throw error.takeRetainedValue() as Error
}
guard let unwrappedPrivateKey = privateKey else {
throw CryptoError.keyCreationFailed
}
return unwrappedPrivateKey
}
Why is using these keychain functions so verbose? A lot of stuff going on. But if you read through it carefully you will see that, again, a key pair is generated, but now we actually hang on to it. See that kSecAttrIsPermanent
being give a value of true
?
Also notice that there is some code to deal with a simulator not having a Secure Enclave.
Loading a key
We did most of the hard work already now. We also need some code to load a key. We’ll get to choosing between loading and generating in a bit. Key take away in the next bit is that the resulting SecKey
is optional. We just might try and load a key and if it fails, we create one…
private func loadKey(name: String) -> SecKey? {
let tag = name
let query: [String: Any] = [
kSecClass as String: kSecClassKey,
kSecAttrApplicationTag as String: tag,
kSecAttrKeyType as String: self.keyType,
kSecAttrKeyClass as String: kSecAttrKeyClassPrivate,
kSecReturnRef as String: true
]
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
guard status == errSecSuccess else {
return nil
}
return (item as! SecKey) // swiftlint:disable:this force_cast
}
Let’s encrypt
Now we are getting to the good part. Let’s encrypt (pun intended).
func encrypt(data clearTextData: Data) throws -> Data? {
let key = try loadKey(name: keyName) ?? makeAndStoreKey(name: keyName)
guard let publicKey = SecKeyCopyPublicKey(key) else {
// Can't get public key
return nil
}
let algorithm: SecKeyAlgorithm = self.algorithm
guard SecKeyIsAlgorithmSupported(publicKey, .encrypt, algorithm) else {
os_log("Can't encrypt. Algorithm not supported.", log: Log.crypto, type: .error)
return nil
}
var error: Unmanaged<CFError>?
let cipherTextData = SecKeyCreateEncryptedData(publicKey, algorithm,
clearTextData as CFData,
&error) as Data?
if let error = error {
os_log("Can't encrypt. %{public}@", log: Log.crypto, type: .error, (error.takeRetainedValue() as Error).localizedDescription)
return nil
}
guard cipherTextData != nil else {
os_log("Can't encrypt. No resulting cipherTextData", log: Log.crypto, type: .error)
return nil
}
os_log("Encrypted data.", log: Log.crypto, type: .info)
return cipherTextData
}
Did you notice the optional chaining on the loadKey
call?
Let’s decrypt
Having encrypted data is fun and all, but we do need some way to decrypt it again. Notice that in the next bit, being unable to load a key is a failure. When decrypting we do expect that a key is already present.
func decrypt(data cipherTextData: Data) -> Data? {
guard let key = loadKey(name: keyName) else { return nil }
let algorithm: SecKeyAlgorithm = self.algorithm
guard SecKeyIsAlgorithmSupported(key, .decrypt, algorithm) else {
os_log("Can't decrypt. Algorithm not supported.", log: Log.crypto, type: .error)
return nil
}
var error: Unmanaged<CFError>?
let clearTextData = SecKeyCreateDecryptedData(key,
algorithm,
cipherTextData as CFData,
&error) as Data?
if let error = error {
os_log("Can't decrypt. %{public}@", log: Log.crypto, type: .error, (error.takeRetainedValue() as Error).localizedDescription)
return nil
}
guard clearTextData != nil else {
os_log("Can't decrypt. No resulting cleartextData.", log: Log.crypto, type: .error)
return nil
}
os_log("Decrypted data.", log: Log.crypto, type: .info)
return clearTextData
}
Conclusions
A couple things to notice in all of the above.
- I like using
os_log
, it has some drawbacks, but the sheer convenience of being able to get some grasp of what has happened on a remote device is very useful. Provided you have adequate logging. Look up sysdiagnose. There is a special Vulcan Death Grip out there that actually allows you to get diagnostic info (including os_log) from a device. https://developer.apple.com/bug-reporting/profiles-and-logs/?platform=ios - Obviously the data you encrypt needs to be stored to and read from storage.
- The code mentioned in this blogpost is directly related to my work here: https://github.com/eduvpn/apple/blob/master/EduVPN/Models/Crypto.swift