2

entitlements photoHello I have a log in view that uses face recognition to authenticate the user and If the user is authenticated it reads their log in info from keychain if they have it saved. For some reason all this functionality isn't working, Ive looked at some other SO threads and was not able to get it working. I think something is wrong with both the save and read functions. I was able to get the authentication function working however. Id appreciate any help.

import SwiftUI
import LocalAuthentication
import AuthenticationServices

struct LogInView: View {
    @State private var email = ""
    @State private var password = ""
    var body: some View {
        ZStack(alignment: .top){
            GeometryReader { geometry in
                VStack {
                    HStack{
                            Button {
                                save(email: email, password: password)
                            } label: {
                                Text("Save password")
                            }
                        
                    }.padding(.top, 10)
                    VStack(spacing: 15){
                        CustomTextField(imageName: "envelope", placeHolderText: "Email", text: $email)
                        CustomTextField(imageName: "lock", placeHolderText: "Password", isSecureField: true, text: $password)
                    }
                }
            }
        }
        .onAppear(perform: authenticate)
    }
    func save(email: String, password: String) {
        let emailData = email.data(using: .utf8)!
        let passwordData = password.data(using: .utf8)!
        
        let query: [String: Any] = [
            kSecClass as String: kSecClassInternetPassword,
            kSecAttrService as String: "https://hustle.page",
            kSecAttrAccount as String: emailData,
            kSecValueData as String: passwordData
        ]
        let saveStatus = SecItemAdd(query as CFDictionary, nil)
        if saveStatus == errSecDuplicateItem {
            update(email: email, password: password)
        }
    }
    func update(email: String, password: String) {
        let emailData = email.data(using: .utf8)!
        let passwordData = password.data(using: .utf8)!

        let query: [String: Any] = [
            kSecClass as String: kSecClassInternetPassword,
            kSecAttrService as String: "https://hustle.page",
            kSecAttrAccount as String: emailData
        ]
            
        let updatedData: [String: Any] = [
            kSecValueData as String: passwordData
        ]
        
        SecItemUpdate(query as CFDictionary, updatedData as CFDictionary)
    }
    func read(service: String) -> (String, String)? {
        let query: [String: Any] = [
            kSecClass as String: kSecClassInternetPassword,
            kSecAttrService as String: service,
            kSecReturnAttributes as String: true,
            kSecReturnData as String: true,
            kSecMatchLimit as String: kSecMatchLimitAll
        ]
        
        var result: AnyObject?
        let status = SecItemCopyMatching(query as CFDictionary, &result)
        
        if status == errSecSuccess, let items = result as? [[String: Any]], let item = items.first {
            if let account = item[kSecAttrAccount as String] as? String,
               let passwordData = item[kSecValueData as String] as? Data,
               let password = String(data: passwordData, encoding: .utf8) {
                return (account, password)
            }
        }
        return nil
    }
    func authenticate() {
        let context = LAContext()
        var error: NSError?

        if context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) {
            let reason = "Secure Authentication."

            context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: reason) { success, authenticationError in
                if success {
                    if let loginInfo = read(service: "https://hustle.page") {
                        let (email, password) = loginInfo
                    }
                }
            }
        }
    }
}
ahmed
  • 341
  • 2
  • 9
  • maybe my app can't access keychain because its still in development and I haven't registered it to apple yet. – ahmed Jul 09 '23 at 00:55
  • maybe this recent post could be of help: https://stackoverflow.com/questions/76625265/how-to-save-credentials-to-keychain-swift/76625331 including the App.entitlements info – workingdog support Ukraine Jul 09 '23 at 01:20
  • @workingdogsupportUkraine when I read from keychain do I have to pass in the account (username or email) into the query? Or is it enough to only pass in the service? When my user signs into the app they do not have the email saved so I cannot pass in the account to read from keychain. – ahmed Jul 09 '23 at 02:24
  • For each thing (eg. password) you want stored in Keychain, you need all 3 things (service, account, password) to uniquely identify the password to save in Keychain. Then when you want to read the password, you need to provide the corresponding service and account. You choose what you use for those service and account. – workingdog support Ukraine Jul 09 '23 at 02:38
  • @workingdogsupportUkraine so basically I can't read a password saved in keychain without have the email or username? If so then how does instagram for example autofill the email and password fields after Face ID authentication goes through? That is the functionality I am after – ahmed Jul 09 '23 at 06:34

1 Answers1

1

For each item saved in the Keychain, you typically have a tuple of (service, account, password) to uniquely identify the password. When you want to read the password, you need to provide the corresponding service and account.

Here, service is generally the URL or identifier of your app or web service, and account is typically the user's username or email. So, to read a password saved in Keychain, you generally need the associated service and account (which is usually the email or username in your case).

That is why apps like Instagram can autofill the email and password fields after Face ID authentication goes through. They previously saved these values into the Keychain and, upon successful Face ID authentication, they fetch and autofill these saved values. That requires them to know the service and account (email or username) associated with the saved password.

So, in your current implementation, you cannot read a password from Keychain without knowing the associated service and account (email). If you have multiple accounts (emails), each one should have its own corresponding password saved separately in Keychain.


From your code, the save and update functions use Data instead of String:

func save(email: String, password: String) {
    let emailData = email.data(using: .utf8)!  // <-- Here you are converting the email into Data
    let passwordData = password.data(using: .utf8)!
    
    let query: [String: Any] = [
        kSecClass as String: kSecClassInternetPassword,
        kSecAttrService as String: "https://hustle.page",
        kSecAttrAccount as String: emailData,  // <-- Here you are saving the email Data into Keychain
        kSecValueData as String: passwordData
    ]
    let saveStatus = SecItemAdd(query as CFDictionary, nil)
    if saveStatus == errSecDuplicateItem {
        update(email: email, password: password)
    }
}

func update(email: String, password: String) {
    let emailData = email.data(using: .utf8)!  // <-- Here you are converting the email into Data again
    let passwordData = password.data(using: .utf8)!

    let query: [String: Any] = [
        kSecClass as String: kSecClassInternetPassword,
        kSecAttrService as String: "https://hustle.page",
        kSecAttrAccount as String: emailData  // <-- And here you are saving the email Data into Keychain again
    ]
        
    let updatedData: [String: Any] = [
        kSecValueData as String: passwordData
    ]
    
    SecItemUpdate(query as CFDictionary, updatedData as CFDictionary)
}

In both the save and update functions, you are converting the email to Data and then storing that Data into Keychain for the account (kSecAttrAccount). However, kSecAttrAccount expects a String type, not Data. That is likely causing your issues with saving and reading the login info.

The same conversion problem is found in the read method, where you try to read the email (account) as a String:

if let account = item[kSecAttrAccount as String] as? String,  // <-- Here you are trying to read the email as a String
   let passwordData = item[kSecValueData as String] as? Data,
   let password = String(data: passwordData, encoding: .utf8) {
    return (account, password)
}

That would not work correctly because you initially stored the email as Data, not as a String.


This should work better:

func save(email: String, password: String) {
    let passwordData = password.data(using: .utf8)!
    
    let query: [String: Any] = [
        kSecClass as String: kSecClassGenericPassword, // <-- Change this
        kSecAttrService as String: "https://hustle.page",
        kSecAttrAccount as String: email,
        kSecValueData as String: passwordData
    ]
    let saveStatus = SecItemAdd(query as CFDictionary, nil)
    if saveStatus == errSecDuplicateItem {
        update(email: email, password: password)
    }
}

func update(email: String, password: String) {
    let passwordData = password.data(using: .utf8)!
    
    let query: [String: Any] = [
        kSecClass as String: kSecClassGenericPassword, // <-- Change this
        kSecAttrService as String: "https://hustle.page",
        kSecAttrAccount as String: email
    ]
    
    let updatedData: [String: Any] = [
        kSecValueData as String: passwordData
    ]
    
    SecItemUpdate(query as CFDictionary, updatedData as CFDictionary)
}

As noted by Rob Napier in the comments, kSecClassInternetPassword, used for storing Internet passwords that are associated with an Internet server, does not support kSecAttrService, so we need kSecClassGenericPassword for general login storage (username and password).

In your read method, you should change kSecMatchLimit as String: kSecMatchLimitAll to kSecMatchLimit as String: kSecMatchLimitOne, because you are storing one account per service.

func read(service: String) -> (String, String)? {
    let query: [String: Any] = [
        kSecClass as String: kSecClassGenericPassword, // <-- Change this
        kSecAttrService as String: service,
        kSecReturnAttributes as String: true,
        kSecReturnData as String: true,
        kSecMatchLimit as String: kSecMatchLimitOne
    ]
        
    var result: AnyObject?
    let status = SecItemCopyMatching(query as CFDictionary, &result)
        
    if status == errSecSuccess, let items = result as? [[String: Any]], let item = items.first {
        if let account = item[kSecAttrAccount as String] as? String,
           let passwordData = item[kSecValueData as String] as? Data,
           let password = String(data: passwordData, encoding: .utf8) {
            return (account, password)
        }
    }
    return nil
}

Regarding the completion block in authenticate() function, it is important to ensure that UI-related changes are made on the main thread:

func authenticate() {
    let context = LAContext()
    var error: NSError?

    if context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) {
        let reason = "Secure Authentication."

        context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: reason) { [weak self] success, authenticationError in
            DispatchQueue.main.async {
                if success {
                    if let loginInfo = self?.read(service: "https://hustle.page") {
                        let (email, password) = loginInfo
                        self?.email = email
                        self?.password = password
                    }
                }
            }
        }
    }
}

That should give you the desired effect of filling in the email and password fields after successful face recognition authentication. Just make sure that you have saved the credentials before trying to read them.

Make sure to check the results of the SecItemAdd, SecItemUpdate, and SecItemCopyMatching functions and handle errors appropriately.


I tried adding some debug at the end of the save func by calling the read function. When I do so it appears that the result of the read function is nil. I also printed out the "status" var in the read function, and it is 0 also indicating no error

The SecItemCopyMatching function returns the status errSecSuccess (which is 0) and a result only if it finds an item that matches the query. If it does not find an item, the status will still be errSecSuccess, but the result will be nil.

Your query in the read function is expecting a list of items ([[String: Any]]), but you are using kSecMatchLimitOne in the query, which means the function will return only a single item (not in a list). So you should expect a single item ([String: Any]) as a result, not a list of items.

Here is how you can modify your read function:

func read(service: String) -> (String, String)? {
    let query: [String: Any] = [
        kSecClass as String: kSecClassGenericPassword,
        kSecAttrService as String: service,
        kSecReturnAttributes as String: true,
        kSecReturnData as String: true,
        kSecMatchLimit as String: kSecMatchLimitOne
    ]
    
    var result: AnyObject?
    let status = SecItemCopyMatching(query as CFDictionary, &result)
    
    if status == errSecSuccess, let item = result as? [String: Any] {
        if let account = item[kSecAttrAccount as String] as? String,
           let passwordData = item[kSecValueData as String] as? Data,
           let password = String(data: passwordData, encoding: .utf8) {
            return (account, password)
        }
    }
    return nil
}

With this change, you should be able to correctly fetch a single item from the Keychain.

VonC
  • 1,262,500
  • 529
  • 4,410
  • 5,250
  • I believe this will fail because kSecClassInternetPassword doesn't support [kSecAttrService](https://developer.apple.com/documentation/security/ksecattrservice/). I think you want to change that to [kSecClassGenericPassword](https://developer.apple.com/documentation/security/ksecclassgenericpassword). (I haven't tested it to make sure the rest of the code work, but this is a fairly common mistake in keychain work. All the calls really need to check their return values, and assert if they're not `errSecSuccess` to catch those easy mistakes.) – Rob Napier Jul 11 '23 at 13:52
  • 1
    @RobNapier Good point, thank you for the feedback. I have edited the answer accordingly. – VonC Jul 11 '23 at 15:25
  • @VonC hello thanks a lot. When I download the app on my iPhone and try the save function I get "0" when I print the "saveStatus" var which indicates no errors. But when I go into keychain and search for the new entry I cannot find it. I tried saving multiple log ins and deleting the app and retrying but I cannot get it to save in keychain. I have keychain on in settings so im not sure what's going on. In another SO thread I saw that I need the correct entitlements, I will attach a screen shot in my original question of what I have now. Do I need to update my plist? Id appreciate the help. – ahmed Jul 11 '23 at 18:41
  • @RobNapier maybe you have an idea of what's going on^. Thanks. – ahmed Jul 11 '23 at 18:41
  • @ahmed Are looking in the correct place? The Keychain data saved by your app is not directly visible in the user-accessible area of Keychain on your device or Mac. Keychain Access on macOS or Keychain settings on iOS only show passwords saved for network services, websites, and other user-visible passwords. The Keychain data used by an app, like the one you are saving, is stored in the app's own Keychain container and is not directly user-accessible. You can access and manage this data programmatically from your app, but it will not show up in Keychain Access or Keychain settings. – VonC Jul 11 '23 at 18:45
  • @ahmed Plus, Keychain data is sandboxed per app (or per app group), so you need to have the Keychain Sharing entitlement enabled if you want to share Keychain data between apps or app extensions. If you are not intending to share data, you do not need to worry about this. But if you do intend to share data, you need to enable the Keychain Sharing entitlement and specify the Keychain access groups that your app needs to access. You can do this in the Capabilities section of your app target settings in Xcode. – VonC Jul 11 '23 at 18:45
  • @ahmed to confirm that your data is actually being saved and retrieved correctly, you could add some more debug output to your app. For example, after calling `SecItemAdd`, you could call your `read` function and print out the result to confirm that the data was saved correctly. – VonC Jul 11 '23 at 18:46
  • @ahmed Note: you do not need to add anything to your Info.plist to use Keychain in your app, unless you want to share Keychain items with other apps or app extensions, in which case you would need to enable the Keychain Sharing entitlement and configure it properly. But for simply saving and reading Keychain data within a single app, no plist changes are necessary. – VonC Jul 11 '23 at 18:46
  • @VonC hello I tried adding some debug at the end of the save func by calling the read function. When I do so it appears that the result of the read function is nill. I also printed out the "status" var in the read function and it is 0 also indicating no error. It seems that the read func doesn't get past the line "if status == errSecSuccess, let items = result as? [[String: Any]], let item = items.first {" so since there is no error "status == errSecSuccess" should be evaluated to true but the rest of the if statement breaks it. – ahmed Jul 11 '23 at 18:59
  • @ahmed I have edited the answer to address your last comment. – VonC Jul 11 '23 at 19:19
  • 1
    @VonC thanks so much it works now, smart man. If I had more reputation points to give you I would. Appreciate it boss! – ahmed Jul 11 '23 at 19:22
  • @VonC maybe you can help with this, Ive been struggling: https://stackoverflow.com/questions/76792454/closing-keyboard-causes-matched-geometry-effect-to-execute-swift-ui – ahmed Jul 29 '23 at 18:27
  • @VonC from your knowledge is it smart to use this method to save private keys for end to end encryption as well? – ahmed Aug 04 '23 at 07:47
  • @ahmed Yes, storing private keys in the Keychain is a smart choice and a common practice for end-to-end encryption scenarios in iOS development. But keep in mind "[Is the iOS keychain encrypted without device passcode?](https://stackoverflow.com/q/34914090/6309)". – VonC Aug 04 '23 at 08:29
  • @VonC maybe you can help: https://stackoverflow.com/questions/76839736/swift-mklocalsearch-only-yields-us-results-how-to-expand-search – ahmed Aug 05 '23 at 00:58
  • @VonC also if keychain is a good spot to store private keys then how do I make sure that the private key gets synced across devices logged into the same iCloud? Because I believe currently saving data to keychain using your method will only save it to the specified device and not other devices logged into the same iCloud. Id appreciate the help. – ahmed Aug 05 '23 at 04:36
  • @ahmed You would need the [iCloud Keychain](https://support.apple.com/en-us/HT204085) for that, and to use the [`kSecAttrSynchronizable` key](https://developer.apple.com/documentation/security/ksecattrsynchronizable). That would be a good question on its own. – VonC Aug 05 '23 at 19:42