How To Secure iOS User Data: Keychain Services and Biometrics with SwiftUI

Learn how to integrate keychain services and biometric authentication into a simple password-protected note-taking SwiftUI app. By Bill Morefield.

4.9 (11) · 1 Review

Download materials
Save for later
Share
You are currently viewing page 2 of 4 of this article. Click here to view the first page.

Searching for Keychain Items

The steps to read an item from the keychain mirror those to add the item. Add the following new method to the end of the KeychainWrapper class:

func getGenericPasswordFor(account: String, service: String) throws -> String {
  let query: [String: Any] = [
    // 1
    kSecClass as String: kSecClassGenericPassword,
    kSecAttrAccount as String: account,
    kSecAttrService as String: service,
    // 2
    kSecMatchLimit as String: kSecMatchLimitOne,
    // 3
    kSecReturnAttributes as String: true,
    kSecReturnData as String: true
  ]
}

Again, the first step when reading an item from the keychain is to create the appropriate query:

  1. You supplied kSecClass, kSecAttrAccount and kSecAttrService when adding the password to the keychain. You can now use these values to find the item in the keychain.
  2. You use kSecMatchLimit to tell Keychain Services you expect a single item as a search result.
  3. The final two parameters in the dictionary tell Keychain Services to return all data and attributes for the found value.

After the query, add the following code:

var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
guard status != errSecItemNotFound else {
  throw KeychainWrapperError(type: .itemNotFound)
}
guard status == errSecSuccess else {
  throw KeychainWrapperError(status: status, type: .servicesError)
}

First, you define an optional CFTypeRef variable to hold the value Keychain Services will hopefully find. You then call SecItemCopyMatching(_:_:). You provide the query and a pointer to the destination value. This function searches the keychain for a match and copies the match to item.

Note: This may be a pattern you’ve not seen before. The ampersand (“&”) before a parameter means it’s a pointer to a memory slot rather than the value itself. The C function will update the memory at that location with a new value.

Again, the status code provides error information. This code contains a specific error when Keychain Services doesn’t find the requested item.

Now you have the keychain item, but as a CFTypeRef. Add the following code at the end of getGenericPasswordFor(account:service:):

// 1
guard 
  let existingItem = item as? [String: Any],
  // 2
  let valueData = existingItem[kSecValueData as String] as? Data,
  // 3
  let value = String(data: valueData, encoding: .utf8)
  else {
    // 4
    throw KeychainWrapperError(type: .unableToConvertToString)
}

// 5
return value

Here’s what’s happening:

  1. Cast the returned CFTypeRef to a dictionary.
  2. Extract the kSecValueData value in the dictionary and cast it to Data.
  3. Attempt to convert the data back to a string, reversing what you did when storing the password.
  4. If any of these steps return nil, this means the data couldn’t be read. You throw an error.
  5. If the casts and conversions succeed, you return the string containing the stored password.

Now you’ve implemented all the capabilities needed to store and retrieve a keychain item. Next, you’ll hook up your user interface so you can see it work!

Using Keychain Services in SwiftUI

Open NoteData.swift under the Models group. Access to the password from the app uses two methods: getStoredPassword() reads the password, and updateStoredPassword(_:) sets the password. There’s also a property, isPasswordBlank.

First, delete the statement let passwordKey = "Password" at the start of the class.

Now replace the existing getStoredPassword() method with this:

func getStoredPassword() -> String {
  let kcw = KeychainWrapper()
  if let password = try? kcw.getGenericPasswordFor(
    account: "RWQuickNote",
    service: "unlockPassword") {
    return password
  }

  return ""
}

This method creates a KeychainWrapper and calls getGenericPasswordFor(account:service:) to read the password and return it. The try? expression converts an exception to a nil. This will cause the method to return an empty string if the search was unsuccessful.

Next, replace updateStoredPassword(_:) with this:

func updateStoredPassword(_ password: String) {
  let kcw = KeychainWrapper()
  do {
    try kcw.storeGenericPasswordFor(
      account: "RWQuickNote",
      service: "unlockPassword",
      password: password)
  } catch let error as KeychainWrapperError {
    print("Exception setting password: \(error.message ?? "no message")")
  } catch {
    print("An error occurred setting the password.")
  }
}

You use KeychainWrapper to set a password using the same account and service. For this app, you’ll print any errors to the console.

Now build and run. The app again asks you to set a password when the app runs. But your app no longer reads from UserDefaults, which is not secure. Instead, it uses the encrypted keychain to store and retrieve the password.

Password Prompt

Enter a new password, and you’ll see the note from your earlier run. Tap the lock button twice. This locks and then unlocks the note, using the password you just set. Your password works, and the note appears!

You can now add a password to your keychain, and you can retrieve it to authenticate the user. But you still need to add a few more methods to complete the implementation of Keychain Services in your app.

Updating a Password in the Keychain

With the app running and the note unlocked, tap the double-arrow button to change the password. Enter a new password and tap Set Password. An error appears in the console window:

Exception Text

You can’t add something to the keychain if the item already exists! Instead, you need to update the stored item.

Open KeychainServices.swift. Add the following code at the end of your KeychainWrapper class:

func updateGenericPasswordFor(
  account: String,
  service: String,
  password: String
) throws {
  guard let passwordData = password.data(using: .utf8) else {
    print("Error converting value to data.")
    return
  }
  // 1
  let query: [String: Any] = [
    kSecClass as String: kSecClassGenericPassword,
    kSecAttrAccount as String: account,
    kSecAttrService as String: service
  ]

  // 2
  let attributes: [String: Any] = [
    kSecValueData as String: passwordData
  ]

  // 3
  let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
  guard status != errSecItemNotFound else {
    throw KeychainWrapperError(
      message: "Matching Item Not Found", 
      type: .itemNotFound)
  }
  guard status == errSecSuccess else {
    throw KeychainWrapperError(status: status, type: .servicesError)
  }
}

This code is similar to storeGenericPasswordFor(account:service:password:), which you added earlier. An update, however, requires two dictionaries, one with the search query and one with the desired changes. Here’s the breakdown:

  1. The search query specifies the data you want to update. You provide the attributes as you did with the search query you created earlier, but you don’t use search parameters such as match limit and return attributes. The function will update all matching entries.
  2. The second dictionary contains the data to update. You may specify any or all attributes valid for the class, but you only include the ones you want to change. Here you specify only the new password. But you could also set service or account if you wanted to store new values for those attributes.
  3. SecItemUpdate(_:_:) uses the contents of the two dictionaries above and performs the update. The most common error you’ll see is The specified attribute does not exist. This error indicates that Keychain Services found nothing matching the search query.

You don’t want to have to keep checking and deciding if you need to write a new keychain item or update an existing one, or dealing with the errors that come up if you call the wrong method. You’re going to fix that now.

In storeGenericPasswordFor(account:service:password:), find the switch status statement and add a new case above the default:

case errSecDuplicateItem:
  try updateGenericPasswordFor(
    account: account, 
    service: service, 
    password: password)

errSecDuplicateItem is the status returned when storing an existing item. Now, if you attempt to store an existing item, you’ll fall back to updating it instead.

Build and run. Use the password you set earlier to unlock the note. (Remember that your password change, above, did not complete. The attempt to add an item that already existed caused an exception.) Try again to change the password — success!

Now your app can add, retrieve, and update a password. But there’s still one action missing. You need to be able to delete a value from the keychain.