The Secure Enclave
The Secure Enclave is a pivotal part of most modern Apple devices. On your iPhone, it's what keeps secure your most sensitive information. Information such as your biometric data, to use with Face & Touch ID and your payment information that can be stored within Apple Pay. It's a dedicated distinct hardware component, isolated from the main processor making it super secure. It being isolated, means it can't be directly accessed by the main processor or any other applications that may find a way to gain un-authorized access. It also means this data will only ever be accessible via the device that encrypted it. In essence, the Secure Enclave acts as a secure vault within the iPhone's hardware architecture, dedicated to managing and protecting sensitive information critical to the device's security features.
Often people's first assumption is that the Secure Enclave includes storage for all types of data, similar to like how we use Apple Keychain or UserDefaults services, but it doesn't. What it can do is use Apple's distinct Secure Enclave algorithms to provide and store encryption keys and other cryptographic material. The Secure Enclave only supports P256 elliptic curve keys and offers around 4MB of storage, which is plenty for key storage.
Recently we had a task to utilise the secure enclave to enhance security for the data an application stored and provide an extra layer to protect that sensitive information. Interested in knowing how we did it? Then let's go...
How we can utilise it
As previously mentioned, the Secure Enclave is not somewhere we can simply store any type of data we have.
In fact, for our utilisation, we are going to continue to use the keychain for storage, but instead of simply storing the raw data in the keychain, we are going to build a wrapper that can make use of the Secure Enclave to encrypt data before it's stored and decrypt at the point it's fetched. This provides us with the security that even if someone somehow accesses that data in the software, you can rest assured that it is encrypted and protected at the hardware level. That's pretty neat. Not only that, with features provided by Apple's CryptoKit library, we can even put restraints on when this data can be decrypted, for example, when the device is locked or when your app is in the background.
We're going to step through the code seeing how we can store our data while encrypting and decrypting it with the Secure Enclave. If you would like to see the full EnclaveWrapper
code, it is available at the bottom of this page.
Initialising our EnclaveWrapper
Before we can start, make sure you've imported CryptoKit
, LocalAuthentication
& KeychainAccess
from Apple. We will be making use of all of them in our EnclaveWrapper
.
import Foundation
import CryptoKit
import LocalAuthentication
import KeychainAccess
Our EnclaveWrapper
requires a dependency on somewhere to store our EnclaveKey. In our case, we want to use our KeychainWrapper
but you may have other places you want to store it or you may want this dependency decoupled for testing purposes. Because of this, the EnclaveWrapper
has a property keyAccess
that conforms to the EnclaveWrapperKeyAccess
protocol.
The EnclaveWrapperKeyAccess
protocol allows us to read and write our EnclaveWrapperKey
when we wrap and unwrap our data. Below we have a class that uses our KeychainWrapper
to align with the EnclaveWrapperKeyAccess
protocol for use with the EnclaveWrapper
.
public class EnclaveWrapper {
let keyAccess: EnclaveWrapperKeyAccess
public init(keyAccess: EnclaveWrapperKeyAccess) {
self.keyAccess = keyAccess
}
...
}
public protocol EnclaveWrapperKeyAccess {
var enclaveKey: EnclaveWrapperKey? { get }
func updateKey(_ key: EnclaveWrapperKey) throws
}
public class EnclaveKeychainAccess: EnclaveWrapperKeyAccess {
private let privateKeyTag = "com.demoApp.secureEnclaveDemo.secureEnclavePrivateKey"
public init() {}
public var enclaveKey: EnclaveWrapperKey? {
return try? Keychain.shared.get(key: privateKeyTag, ofType: EnclaveWrapperKey.self)
}
public func updateKey(_ key: EnclaveWrapperKey) throws {
try Keychain.shared.set(value: key, key: privateKeyTag)
}
}
Sealing our data
The seal data function we're going to look into...
//seals data in AES.GCM seal and returns combined Data
public func sealData<T: Encodable>(data: T) throws -> Data {
guard SecureEnclave.isAvailable else {
throw EnclaveWrapperError.secureEnclaveUnavailable
}
//fetch cached key if we have one
var enclaveKey: EnclaveWrapperKey? = keyAccess.enclaveKey
//if we dont have a cached key, generate a new one
if enclaveKey == nil {
let newPrivateKey = try SecureEnclave.P256.KeyAgreement.PrivateKey()
let newKey = EnclaveWrapperKey(privateKeyDataRepresentation: newPrivateKey.dataRepresentation,
salt: generateRandomSalt())
//cache it for future use
try keyAccess.updateKey(newKey)
enclaveKey = newKey
}
//check we have keys at this point, either from cache or generated
guard let validKey = enclaveKey else {
throw EnclaveWrapperError.keyGenerationFailed
}
let symmetricKey = try validKey.symmetricKey()
let encodedData = try JSONEncoder().encode(data)
return try AES.GCM.seal(encodedData, using: symmetricKey).combined!
}
Hardware checks
First off we need to check the secure enclave is available on the device we're using. This will be any device that uses the A7 chip (iPhone 5S or later). If we can't use the enclave, we simply throw an error.
guard SecureEnclave.isAvailable else {
throw EnclaveWrapperError.secureEnclaveUnavailable
}
Key Handling
Secondly, we will check if we have an enclave private key already cached. We do this by using our keyAccess
property we set up at initialisation.
//fetch cached key if we have one
var enclaveKey: EnclaveWrapperKey? = keyAccess.enclaveKey
Generating a key
In the case that this is a users first run through, they wont have a stored key so we will need to create one for them.
For this we have the EnclaveWrapperKey
struct. This combines our private key along with salt data that will later be used to generate a symmetric key for sealing and unlocking our data.
public struct EnclaveWrapperKey: Codable {
let privateKeyDataRepresentation: Data
let salt: Data
}
Below you can see how we create a new EnclaveWrapperKey
.
- We use CryptoKit to generate a new public key using the Secure Enclave.
let newPrivateKey = try SecureEnclave.P256.KeyAgreement.PrivateKey()
- We then generate random salt data that is used for the derivation of the symmetric key.
private func generateRandomSalt() -> Data {
var data = Data(count: 32)
_ = data.withUnsafeMutableBytes { buffer in
SecRandomCopyBytes(kSecRandomDefault, 32, buffer.baseAddress!)
}
return data
}
- We then combine the two to make our new
EnclaveWrapperKey
. We then need to cache a representation of this key usingkeyAccess
once more. It's important to remember that the data representation stored in the Keychain will not simple be enough to unlock our data. We will need to interact with the Secure Enclave using this data to get our PrivateKey.
let newKey = EnclaveWrapperKey(privateKeyDataRepresentation: newPrivateKey.dataRepresentation,
salt: generateRandomSalt())
//cache it for future use
try keyAccess.updateKey(newKey)
Utilising a cached key
Presuming this is our second time storing data and we have already generated a key previously, meaning one is available in the Keychain. We can use the cached key found in our keyAccess
dependency.
var enclaveKey: EnclaveWrapperKey? = keyAccess.enclaveKey
Our EnclaveWrapperKey
has two helpful functions. The first being a private function, that allows us to retrieve our SecureEnclave key.
func privateKey() throws -> SecureEnclave.P256.KeyAgreement.PrivateKey
This function can use the EnclaveWrapperKey.privateKeyDataRepresentation
to interact with the SecureEnclave using CryptoKit and return our SecureEnclave.P256.KeyAgreement.PrivateKey
.
Another thing to note is, you need to provide an authentication context whenever accessing these private keys. Depending on the key and configurations, you may need to display context and reasoning as to why you need to authenticate to access this key using biometric or password checks.
private func privateKey() throws -> SecureEnclave.P256.KeyAgreement.PrivateKey {
let context = LAContext()
context.localizedReason = "Authenticate to retrieve the private key"
let privateKey = try SecureEnclave.P256.KeyAgreement.PrivateKey(dataRepresentation: self.privateKeyDataRepresentation, authenticationContext: context)
return privateKey
}
Symmetric Keys
The second EnclaveWrapperKey
function is publicly accessible. This is because it generates a symmetric key by combining the privateKey()
function key and the salt data stored within the EnclaveWrapperKey
. It first takes a private key and generates a shared secret using its private and public key combined. With this shared secret, we can then derive a symmetric key using SHA256 and the salt. We store the salt and the key together in our EnclaveWrapperKey
as one without the other would not work. Both are required to derive our symmetric key. This symmetric key is what will be required for sealing and opening our data.
func symmetricKey() throws -> SymmetricKey {
//generate symmetric key from shared secret
let privateKey = try privateKey()
let sharedSecret = try privateKey.sharedSecretFromKeyAgreement(with: privateKey.publicKey)
// Perform HKDF to derive the symmetric key from the shared secret
return sharedSecret.hkdfDerivedSymmetricKey(using: SHA256.self,
salt: self.salt,
sharedInfo: Data(),
outputByteCount: 32)
}
For our case, we can use the public key that is accessible via the private key as we are not sharing this data with anyone else. It is purely for security enhancements within our device. Often when sharing data with others, users will share their public keys while keeping their private keys, exactly that, private! Once this key exchange has taken place, both parties will be able to access the data as long as the shared secret is a combination of both their keys. This would be a more common use case and why we need to interact with the Secure Enclave in this way. You can read more about the Diffie–Hellman key exchange here.
The concealing
Now we have our symmetric key, we can encode and then seal our data up by calling AES.GCM.seal(encodedData, using: symmetricKey).combined!
let encodedData = try JSONEncoder().encode(data)
return try AES.GCM.seal(encodedData, using: symmetricKey).combined!
Now I have my seal data function, I can simply call it in my app on any Encodable
object and store my encrypted data like so...
let wrappedData = try EnclaveWrapper.sealData(data: user)
storage.set(wrappedData, key: LocalConstants.userStorageKey)
Opening our sealed data
public func openSealedData<T: Decodable>(type: T.Type, data: Data) throws -> T {
guard SecureEnclave.isAvailable else {
throw EnclaveWrapperError.secureEnclaveUnavailable
}
guard let enclaveKey = keyAccess.enclaveKey else {
//if we dont have a cached key, opening sealed data will not work
throw EnclaveWrapperError.keysNotFound
}
let symmetricKey = try enclaveKey.symmetricKey()
let sealedBox = try AES.GCM.SealedBox(combined: data)
let data = try AES.GCM.open(sealedBox, using: symmetricKey)
return try JSONDecoder().decode(T.self, from: data)
}
Now we've got our sealed data, there will come a time, where we need to unlock it and access it once more. Lets look into how we can do that.
Just like before, we first need to check the enclave is available and fetch our cached enclaveKey
using keyAccess.enclaveKey
. Unlike before there is no need to generate new keys if it can't be found. There is no way we'll be able to unlock our data if we don't have the keys and salt that were used to seal it. In this case, we will just throw an error.
guard let enclaveKey = keyAccess.enclaveKey else {
//if we dont have a cached key, opening sealed data will not work
throw EnclaveWrapperError.keysNotFound
}
We then access our symmetric key using our same symmetricKey()
function.
let symmetricKey = try enclaveKey.symmetricKey()
Finally, we can simply create a sealed box using the data passed in like so...
try AES.GCM.SealedBox(combined: data)
We can then open the box with our symmetric key and decode our data. It's important to remember that in order to use our Enclave Wrapper, all data types will need to conform to Codable
.
let sealedBox = try AES.GCM.SealedBox(combined: data)
let data = try AES.GCM.open(sealedBox, using: symmetricKey)
return try JSONDecoder().decode(T.self, from: data)
With this function complete, I can use it in my app to fetch back my User object (that conforms to codable) that I sealed previously...
func getUser() -> User? {
if let wrappedData = storage.get(key: LocalConstants.userStorageKey, ofType: Data.self) {
return try? EnclaveWrapper.openSealedData(type: User.self, data: wrappedData)
}
return nil
}
Conclusion
Now we have an EnclaveWrapper and have decoupled our dependecies on Keychain, we can even write a test to assert our sealing and opening of our data works as expected. Below we use the EnclaveWrapper
to encrypt our Codable data, and once encrypted it can't be decoded into its original form until it's opened with the EnclaveWrapper
. We can then test the data matches.
import XCTest
@testable import EnclaveWrapper
final class EnclaveWrapperTests: XCTestCase {
struct User: Codable {
let firstName: String
let lastName: String
}
class MockEnclaveKeychainAccess: EnclaveWrapperKeyAccess {
var enclaveKey: EnclaveWrapperKey?
func updateKey(_ key: EnclaveWrapperKey) throws {
enclaveKey = key
}
}
func testNewEncryption() {
//mock object we will encrypt and test with
let user = User(firstName: "Joe", lastName: "Bloggs")
let enclaveWrapper = EnclaveWrapper(keyAccess: MockEnclaveKeychainAccess())
guard let encryptedUser = try? enclaveWrapper.sealData(data: user) else {
XCTFail("Failed to encrypt object")
return
}
//prove we can't use encypted data
let encryptedUserDecoded = try? JSONDecoder().decode(User.self, from: encryptedUser)
XCTAssertNil(encryptedUserDecoded)
//now decrypt user using open sealed data
let decryptedUser = try? enclaveWrapper.openSealedData(type: User.self, data: encryptedUser)
//assert data is equal
XCTAssertEqual(user.firstName, decryptedUser?.firstName)
XCTAssertEqual(user.lastName, decryptedUser?.lastName)
}
}
That about wraps it up! (Excuse the pun).
With just those few functions, I can now safely cache all my data with the added dependency of the Secure Enclave hardware capabilities. If the data in the keychain was ever compromised, we can now safely know, it will be encrypted with the Secure Enclave. If you want to read more about the secure enclave, you can find some links to more information below. You can also find the entire EnclaveWrapper code along with a KeychainWrapper that I used with it.
import Foundation
import CryptoKit
import LocalAuthentication
import KeychainAccess
//MARK: - EnclaveWrapperKey
public struct EnclaveWrapperKey: Codable {
let privateKeyDataRepresentation: Data
let salt: Data
}
extension EnclaveWrapperKey {
private func privateKey() throws -> SecureEnclave.P256.KeyAgreement.PrivateKey {
let context = LAContext()
context.localizedReason = "Authenticate to retrieve the private key"
let privateKey = try SecureEnclave.P256.KeyAgreement.PrivateKey(dataRepresentation: self.privateKeyDataRepresentation, authenticationContext: context)
return privateKey
}
func symmetricKey() throws -> SymmetricKey {
//generate symmetric key from shared secret
let privateKey = try privateKey()
let sharedSecret = try privateKey.sharedSecretFromKeyAgreement(with: privateKey.publicKey)
// Perform HKDF to derive the symmetric key from the shared secret
return sharedSecret.hkdfDerivedSymmetricKey(using: SHA256.self,
salt: self.salt,
sharedInfo: Data(),
outputByteCount: 32)
}
}
//MARK: - EnclaveWrapper
public enum EnclaveWrapperError: Error {
case secureEnclaveUnavailable
case saltNotFound
case keyGenerationFailed
case keysNotFound
}
public class EnclaveWrapper {
let keyAccess: EnclaveWrapperKeyAccess
public init(keyAccess: EnclaveWrapperKeyAccess) {
self.keyAccess = keyAccess
}
//MARK: - Public functions
//seals data in AES.GCM seal and returns combined Data
public func sealData<T: Encodable>(data: T) throws -> Data {
guard SecureEnclave.isAvailable else {
throw EnclaveWrapperError.secureEnclaveUnavailable
}
//fetch cached key if we have one
var enclaveKey: EnclaveWrapperKey? = keyAccess.enclaveKey
//if we don't have a cached key, generate a new one
if enclaveKey == nil {
let newPrivateKey = try SecureEnclave.P256.KeyAgreement.PrivateKey()
let newKey = EnclaveWrapperKey(privateKeyDataRepresentation: newPrivateKey.dataRepresentation,
salt: generateRandomSalt())
//cache it for future use
try keyAccess.updateKey(newKey)
enclaveKey = newKey
}
//check we have keys at this point, either from cache or generated
guard let validKey = enclaveKey else {
throw EnclaveWrapperError.keyGenerationFailed
}
let symmetricKey = try validKey.symmetricKey()
let encodedData = try JSONEncoder().encode(data)
return try AES.GCM.seal(encodedData, using: symmetricKey).combined!
}
public func openSealedData<T: Decodable>(type: T.Type, data: Data) throws -> T {
guard SecureEnclave.isAvailable else {
throw EnclaveWrapperError.secureEnclaveUnavailable
}
guard let enclaveKey = keyAccess.enclaveKey else {
//if we dont have a cached key, opening sealed data will not work
throw EnclaveWrapperError.keysNotFound
}
let symmetricKey = try enclaveKey.symmetricKey()
let sealedBox = try AES.GCM.SealedBox(combined: data)
let data = try AES.GCM.open(sealedBox, using: symmetricKey)
return try JSONDecoder().decode(T.self, from: data)
}
//MARK: - Private functions
private func generateRandomSalt() -> Data {
var data = Data(count: 32)
_ = data.withUnsafeMutableBytes { buffer in
SecRandomCopyBytes(kSecRandomDefault, 32, buffer.baseAddress!)
}
return data
}
}
// MARK: - EnclaveKeychainAccess
public protocol EnclaveWrapperKeyAccess {
var enclaveKey: EnclaveWrapperKey? { get }
func updateKey(_ key: EnclaveWrapperKey) throws
}
public class EnclaveKeychainAccess: EnclaveWrapperKeyAccess {
private let privateKeyTag = "com.demoApp.secureEnclaveDemo.secureEnclavePrivateKey"
public init() {}
public var enclaveKey: EnclaveWrapperKey? {
return try? Keychain.shared.get(key: privateKeyTag, ofType: EnclaveWrapperKey.self)
}
public func updateKey(_ key: EnclaveWrapperKey) throws {
try Keychain.shared.set(value: key, key: privateKeyTag)
}
}
// MARK: - KeychainWrapper
public class Keychain {
private let keychain: KeychainAccess.Keychain
public init(service: String) {
self.keychain = .init(service: service)
}
public func get<T: Codable>(key: String, ofType type: T.Type) throws -> T? {
guard let data = try keychain.getData(key) else {
return nil
}
return try JSONDecoder().decode(T.self, from: data)
}
public func set<T: Codable>(value: T?, key: String) throws {
guard let value = value else {
try keychain.remove(key)
return
}
let data = try JSONEncoder().encode(value)
try keychain.set(data, key: key)
}
public func remove(key: String) throws {
try keychain.remove(key)
}
}
public extension Keychain {
static let shared: Keychain = Keychain(service: "com.demoApp.secureEnclaveDemo")
}
Related Articles:
Article Photo by Getty Images