[iOS] Keychain을 사용해보자 🔐

Charlie·2023년 5월 14일
0

프로젝트를 진행하면서 구현했던 것들 중 다른 프로젝트들에서도 사용할 수 있을만한 유용한 것들을 정리하려한다.

그 첫번째로 Apple에서 제공하는 Keychain에 대해서 정리해보자.

기존 UserDefault 활용

예를들어 access token, refresh token을 서버로부터 발급 받으면 이를 UserDefault에 저장하여 사용하곤 했다.

public func getAccessToken() -> String? {
    UserDefaults.standard.string(forKey: "accessToken")
}

public func getRefreshToken() -> String? {
    UserDefaults.standard.string(forKey: "refreshToken")
}

public func saveAccessToken(_ accessToken: String) {
    UserDefaults.standard.set(accessToken, forKey: "accessToken")
}

public func saveRefreshToken(_ refreshToken: String) {
    UserDefaults.standard.set(refreshToken, forKey: "refreshToken")
}

UserDefault를 사용하면 아주 간단히 구현할 수 있지만, 보안상 좋지 않은 방법이다.

KeyChain

우선 다른 블로그 혹은 문서를 보기 전에 공식 문서를 먼저 읽어보고...

Keychain을 사용하면 사용자의 비밀번호에 국한하지 않고, 신용카드 정보 혹은 짧은 메모 등도 사용자가 암호화하고 싶은 것이라면 Keychain 데이터베이스에 암호화하여 저장할 수 있다.

Keychain에 들어가는 친구들은 Item이란 애들인데, Item은 다음과 같이 2개를 갖는다.

  • 저장할 데이터를 암호화한 Data
  • Attributes

여기서 attributes는 Item의 접근성을 설정하고 검색할 수 있게 하는 public한 친구이다.

또 중요한 Keychain의 특징들이 있다.

  1. 앱을 삭제해도 Keychain 정보가 삭제되지 않는다.
  2. Device의 잠금상태와 같이 Keychain도 잠금 기능이 있다. 잠겨있을 때에는 데이터 접근, 암호화, 복호화 등의 기능을 할 수 없다.
  3. 같은 개발자가 개발한 앱 사이에서는 Keychain 정보를 공유할 수 있다.

Keychain Process

Keychain을 활용한 사용자의 프로세스는 다음과 같이 나타낼 수 있다.

처음 (Keychain 데이터 없음)

앱을 처음 실행했다면 Keychain에 어떠한 정보도 저장되어있지 않은 상태이다. 따라서 keychain에서 검색을 하면 correspoding한 결과를 찾을 수 없기 때문에 위 그림의 오른쪽 flow를 진행하게 된다.

오른쪽 flow에서 사용자가 인증을 성공한 credential을 제공하면 SecItemAdd(_:_:) 메소드를 사용하여 해당 정보를 저장한다.

Keychain 데이터 인증 성공

앱을 처음 실행한 경우가 아닌 대부분의 경우에는 위 다이어그램의 중간 flow를 진행하게 된다.
SecItemCopyMatching(_:_:)의 결과로 해당 데이터를 찾고 인증까지 성공하면 사용자는 앱 서비스를 사용할 수 있게 된다.

Keychain 데이터 인증 실패

사용자는 자신이 저장한 암호화된 데이터를 바꿀 수 있다. 예를 들어, 앱을 사용하다가 해당 서비스의 웹사이트에서 비밀번호를 바꾼 상황을 생각해보자.
비밀번호가 바뀐 이후에는 위 그림에서 keychain에서 해당 데이터를 찾아도 인증에서 실패하게 된다. 이 때 왼쪽 flow를 타게 된다.

여기서는 SecItemUpdate(_:_:) 메소드를 통해 현재 저장된 value를 새롭게 인증한 credential의 value로 변경하게 된다.

Keychain 데이터 삭제

마지막으로 SecItemDelete(_:)을 통해 keychain에서 데이터를 삭제할 수 있다.


Attributes?

Keychain에 데이터를 저장할 때, 데이터의 종류에 따라 Item class를 구분할 수 있다.
그리고 해당하는 Item class에 따라 적용할 수 있는 attribues들이 달라진다.

데이터 종류에 따른 Item class는 아래와 같고, 각 class마다 적용할 수 있는 attributes는 너무 많기 때문에 사용할 때 문서를 참고해서 적용하면 될 듯 하다.

Item Attibutes Keys and Values


✨CRUD

Create

struct Credentials {
    var username: String
    var password: String
}

enum KeychainError: Error {
    case noPassword
    case unexpectedPasswordData
    case unhandledError(status: OSStatus)
}

let account = "username"
let password = "password".data(using: String.Encoding.utf8)!
let server = "www.example.com"

// 1. Query 작성
var query: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
                            kSecAttrAccount as String: account,
                            kSecAttrServer as String: server,
                            kSecValueData as String: password]
                            
// 2. SecItemAdd(_:_:) 메소드 호출
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else { throw KeychainError.unhandledError(status: status) }

공식 문서에서는 위와 같이 SecItemAdd(_:_:) 메소드에서 2번째 인자를 nil로 설정하여 결과를 사용하지 않고있다. 그리고KeychainError를 만들어 에러 발생 시 throw를 해주는데, 이 상황과 같이 결과를 받지 않아도 return status를 검사하여 성공했는지 검사를 항상 하라고 말하고 있다.

SecItemAdd(::) 메소드는 인자로 attributes를 CFDictionary형태로 받고, result를 받는다.

지금 프로젝트의 토큰을 저장하는 경우에는 다음과 같이 작성하면 될 듯 하다.

public func addToken(key: String, value token: String) -> Bool {
    let query: [String : Any] = [kSecClass as String: kSecClassGenericPassword,
                                 kSecAttrAccount as String: key,
                                 kSecValueData as String: token.data(using: .utf8) as Any]
    
    let status = SecItemAdd(query as CFDictionary, nil)
    
    guard status == errSecSuccess else {
#if DEBUG
        print("🔥Keychain create Error : \(String(describing: SecCopyErrorMessageString(status, nil)))")
#endif
        return false
    }
#if DEBUG
    print("✨Keychain create success")
#endif
    return true
}

SecItemAdd(:_:_) 메소드 이전에 SecItemDelete(_:) 메소드를 호출해서 중복되는 키체인을 사용하면 update를 create로 대체할 수도 있을 것 같긴 하다..!


Read

// 1. Query 설정
let query: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
                            kSecAttrServer as String: server,
                            kSecMatchLimit as String: kSecMatchLimitOne,
                            kSecReturnAttributes as String: true,
                            kSecReturnData as String: true]

// 2. SecItemCopyMatching(_:_:) 메소드 호출 (errSecItemNotFound 에러 처리하기)
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
guard status != errSecItemNotFound else { throw KeychainError.noPassword }
guard status == errSecSuccess else { throw KeychainError.unhandledError(status: status) }

// 3. result 처리하기
guard let existingItem = item as? [String : Any],
    let passwordData = existingItem[kSecValueData as String] as? Data,
    let password = String(data: passwordData, encoding: String.Encoding.utf8),
    let account = existingItem[kSecAttrAccount as String] as? String
else {
    throw KeychainError.unexpectedPasswordData
}
let credentials = Credentials(username: account, password: password)

공식 문서에서 보여주는 예시는 위와 같다. Attributes들을 살펴보면 Internet Password에 해당하는 키체인이고, 이전에 추가한 server attribute에 해당하는 Item을 검색한다.
kSecMatchLimit을 보면 kSecMatchLimitOne으로 설정되어 결과를 1개로 제한하고 있다. (default임)
마지막으로 attribute와 data 모두 return할 수 있도록 설정한다.

그 다음 SecItemCopyMatcing(::)를 호출하여 검색을 실행할 수 있다.
검색을 실시하고 errSecItemNotFound 에러를 통해 아직 키체인에 등록되지 않은 상황을 처리할 수 있다.

검색에 성공하면 parameter를 어떻게 설정했는지에 따라 반환값이 나오게 된다. 반환 값에는 여러 타입들이 동시에 있기 때문에 반환 Dictionary에서 kSecAttrValueData를 사용하여 원하는 결과들을 추출해야한다.

토큰을 읽는 지금의 경우 아래와 같이 작성하면 되겠다.

public func readToken(key: String) -> String? {
    let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword,
                                kSecAttrAccount as String: key,
                                kSecReturnAttributes as String: true,
                                kSecReturnData as String: true]
    
    var item: CFTypeRef?
    let status = SecItemCopyMatching(query as CFDictionary, &item)
    
    guard status != errSecItemNotFound else {
#if DEBUG
        print("🔥Keychain item for \(key) not found : \(String(describing: SecCopyErrorMessageString(status, nil)))")
#endif
        return nil
    }
    
    guard status == errSecSuccess else {
#if DEBUG
        print("🔥Keychain read Error : \(String(describing: SecCopyErrorMessageString(status, nil)))")
#endif
        return nil
    }
    
    guard let item = item as? [String: Any],
          let tokenData = item[kSecValueData as String] as? Data,
          let token = String(data: tokenData, encoding: .utf8) else {
#if DEBUG
        print("🔥Keychain get token failed : \(String(describing: SecCopyErrorMessageString(status, nil)))")
#endif
        return nil
    }
#if DEBUG
    print("✨Keychain read success : \(token)")
#endif
    return token
}

Update

update를 진행하기 위해서는 우선 키체인에서 해당 키체인을 찾아야한다.
따라서 read할 때와 같이 query를 먼저 작성한다. (찾기만 하고 return을 통해 어떠한 것을 안하기 때문에 다른 attributes들은 필요 없다)
그 다음 새롭게 들어갈 친구를 만들고 SecItemUpdate(::)을 실행하면 된다.

// 1. Query 작성 (return과 관련한 attributes는 필요 없음)
let query: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
                            kSecAttrServer as String: server]

// 2. update될 내용 작성
let account = credentials.username
let password = credentials.password.data(using: String.Encoding.utf8)!
let attributes: [String: Any] = [kSecAttrAccount as String: account,
                                 kSecValueData as String: password]                   

// 3. SetItemUpdate(_:_:) 메소드 호출
let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
guard status != errSecItemNotFound else { throw KeychainError.noPassword }
guard status == errSecSuccess else { throw KeychainError.unhandledError(status: status) }

프로젝트에서는 아래와 같이 코드를 작성했다.

@discardableResult
public func updateToken(key: String, value token: String) -> Bool {
    let prevQuery: [String: Any] = [kSecClass as String: kSecClassGenericPassword,
                                    kSecAttrAccount as String: key]
    let updateQuery: [String: Any] = [kSecValueData as String: token.data(using: .utf8) as Any]
    
    let status = SecItemUpdate(prevQuery as CFDictionary, updateQuery as CFDictionary)
    
    guard status != errSecItemNotFound else {
#if DEBUG
        print("🔥Keychain item for \(key) not found : \(String(describing: SecCopyErrorMessageString(status, nil)))")
#endif
        return false
    }
    
    guard status == errSecSuccess else {
#if DEBUG
        print("🔥Keychain update Error : \(String(describing: SecCopyErrorMessageString(status, nil)))")
#endif
        return false
    }
#if DEBUG
    print("✨Keychain update success")
#endif
    return true
}

Delete

사용자가 로그아웃을 할 때, 키체인에서 password item을 지우면 된다.
delete는 update와 되게 비슷한데, 타겟이 되는 친구만 SecItemDelete(_:) 메소드에게 넘겨주면 된다.

let status = SecItemDelete(query as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else { throw KeychainError.unhandledError(status: status) }

프로젝트에서는 아래와 같이 작성하였다.

@discardableResult
public func deleteToken(key: String) -> Bool {
    let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword,
                                kSecAttrAccount as String: key]
    
    let status = SecItemDelete(query as CFDictionary)
    
    guard status != errSecItemNotFound else {
#if DEBUG
        print("🔥Keychain item for \(key) not found : \(String(describing: SecCopyErrorMessageString(status, nil)))")
#endif
        return false
    }
    
    guard status == errSecSuccess else {
#if DEBUG
        print("🔥Keychain delete Error : \(String(describing: SecCopyErrorMessageString(status, nil)))")
#endif
        return false
    }
#if DEBUG
    print("✨Keychain delete success")
#endif
    return true
}

Error

CRUD 메소드의 반환 타입은 OSStatus 타입이다.
에러 발생 시 출력을 하면 정수만 나오게 되어서 무슨 에러인지 알기가 어려울 때 아래와 같이 자세한 에러를 확인할 수 있다.

let status = SecItemDelete(query as CFDictionary)	// CRUD 메소드
SecCopyErrorMessageString(status, nil)

References

Apple Keychain 공식 문서
[iOS] 토큰 데이터 저장 공간을 Keychain으로 바꿔보자
[iOS] 키체인에 대해서 (서버에서 받은 token을 저장해보자)
[iOS] 키체인 (Keychain Service)

profile
Hello

0개의 댓글