아래는 미디엄 - Keychain Services in Swift (opens new window)
문서에 대한 요약본입니다.

# 키체인

키체인은 데이터 저장을 위한 공간이다. iOS에서는 여러 타입의 저장소를 제공한다. 코어데이터, User Defaults 등이 있지만 키체인의 경우 민감한 개인정보 등을 저장할때 안전하게 보관할 수 있게 해준다.

키체인과 관련된 문서는 다음 링크에 정리해두었으니 참고하자.

# 키체인 매니저

여타 다른 저장소를 활용할때처럼, 키체인도 키체인아이템을 관리해줄 매니저 클래스를 정의하는 형태로 관리하는 것이 좋다. 싱글톤 패턴으로 관리하여 CRUD의 책임을 한 인스턴스에 몰아두도록 구현한다.

class KeychainManager{
    typealias ItemAttributes = [CFString: Any]
    typealias KeychainDictionary = [String: Any]


    static let shared = KeychainManager()

    private init(){}
}

# 쿼리 클래스 정의

CFDictionary의 키값으로 들어가는 데이터들은 raw value형태이기 때문에 매니저 클래스 내에 열거형 인스턴스를 하나 생성해두게 된다.

이때 열거형에 RawRepresentable 프로토콜을 채택하게 되는데, 커스텀 열거형 인스턴스를 생성하여 접근할때 생성자 함수로 rawValue 파라미터를 받는 경우 정상적으로 리턴받기 위해 사용하게 된다.

우리가 커스텀 인스턴스를 생성하여 둘 사이의 크기 비교를 하고싶을때 Comparable 프로토콜을 채택하여 어떤 값을 기준으로 비교하고자 하는지 선택할때 내부 로직을 구현하는 것과 유사한 흐름이다.

extension KeychainManager{
    enum ItemClass: RawRepresentable {
         typealias RawValue = CFString

         case generic
         case password
         case certificate
         case cryptography
         case identity

         init?(rawValue: CFString) {
            switch rawValue {
            case kSecClassGenericPassword:
               self = .generic
            case kSecClassInternetPassword:
               self = .password
            case kSecClassCertificate:
               self = .certificate
            case kSecClassKey:
               self = .cryptography
            case kSecClassIdentity:
               self = .identity
            default:
               return nil
            }
         }

         var rawValue: CFString {
            switch self {
            case .generic:
               return kSecClassGenericPassword
            case .password:
               return kSecClassInternetPassword
            case .certificate:
               return kSecClassCertificate
            case .cryptography:
               return kSecClassKey
            case .identity:
               return kSecClassIdentity
            }
         }
      }
}

# 에러 정의


extension KeychainManager {
   enum KeychainError: Error {
      case invalidData
      case itemNotFound
      case duplicateItem
      case incorrectAttributeForClass
      case unexpected(OSStatus)

      var localizedDescription: String {
         switch self {
         case .invalidData:
            return "Invalid data"
         case .itemNotFound:
            return "Item not found"
         case .duplicateItem:
            return "Duplicate Item"
         case .incorrectAttributeForClass:
            return "Incorrect Attribute for Class"
         case .unexpected(let oSStatus):
            return "Unexpected error - \(oSStatus)"
         }
      }
   }

    private func convertError(_ error: OSStatus) -> KeychainError {
      switch error {
      case errSecItemNotFound:
         return .itemNotFound
      case errSecDataTooLarge:
         return .invalidData
      case errSecDuplicateItem:
         return .duplicateItem
      default:
         return .unexpected(error)
      }
   }
}

# CRUD - Create

class KeychainManager{
    // 나머지..
    func saveItem<T: Encodable>(
        _ item: T,
        itemClass: ItemClass,
        key: String,
        attributes: ItemAttributes? = nil) throws {
            // 1
            let itemData = try JSONEncoder().encode(item)

            // 2
            var query: [String: AnyObject] = [
                kSecClass as String: itemClass.rawValue,
                kSecAttrAccount as String: key as AnyObject,
                kSecValueData as String: itemData as AnyObject
            ]

            // 3
            if let itemAttributes = attributes {
                for(key, value) in itemAttributes {
                    query[key as String] = value as AnyObject
                }
            }

            // 4
            let result = SecItemAdd(query as CFDictionary, nil)

            // 5
            if result != errSecSuccess {
                throw convertError(result)
            }
    }
}

아래는 saveItem 함수에 대한 설명이다.

  1. 파라미터의 itemClass는 저장할 키체인 데이터의 클래스를 지정한다. 키체인 관련 문서에 정리되어 있지만, kSecClassInternetPassword 등 키체인 데이터에 대한 용도를 지정하게 된다.
  2. key 파라미터는 함수 내에서 query 변수에서 사용한다.
  3. attributes 파라미터는 키체인 아이템에 대한 나머지 키값들을 세팅한다.

함수 내의 로직은 다음과 같다.

  1. 파라미터로부터 제네릭 형태로 item을 받는데, 이는 실제 키체인에 저장할 sensitive data에 해당한다. Data타입으로 인코딩해둔다.
  2. 쿼리 변수를 딕셔너리로 생성한다. CFDictionary로 사용하지 않고 일반 딕셔너리로 생성해두며 추후 키체인 API를 호출할때 CFDictionary로 타입캐스팅한다. 순서는 상관없지만, nested로 정의해둔 열거형을 활용하려면 전자의 방식을 택하는 것이 좋다.
  3. 쿼리는 아래 값들을 사용한다.
    • kSecClass는 함수 파라미터의 itemClassrawValue를 받는다. 이는 nested 열거형 타입이다.
    • 예제에서는 kSecAttrAccount 키값에 대한 밸류로 함수 파라미터 key를 할당하고 있는데, 의미적으로 바라봤을때 계정 ID는 고유하기 때문에 이를 기준으로 키체인에서 패스워드를 가져온다고 볼 수 있겠다. 꼭 kSecAttrAccount 키가 아니더라도 다른 키를 사용해볼 수 있다.
    • kSecValueData 키값 밸류에는 인코딩된 데이터를 할당한다.
  4. itemAttributes는 함수 파라미터 attributes를 순회하며 딕셔너리에 키값과 밸류값들을 할당하게 된다.
  5. CRUD의 Create API이므로 SecItemAdd 함수를 호출한다. 쿼리를 CFDictionary로 타입캐스팅해주고, result는 nil값을 전달한다.
  6. OSStatus를 변수에 저장한 뒤 Success상태가 아닌 경우 converError를 호출해준다.

# CRUD - Read

예제에 나와있는 코드이다.

func retrieveItem<T: Decodable>(
   ofClass itemClass: ItemClass,
   key: String, attributes:
   ItemAttributes? = nil) throws -> T {

   // 1
   var query: KeychainDictionary = [
      kSecClass as String: itemClass.rawValue,
      kSecAttrAccount as String: key as AnyObject,
      kSecReturnAttributes as String: true,
      kSecReturnData as String: true
   ]

   // 2
    if let itemAttributes = attributes {
        for(key, value) in itemAttributes {
            query[key as String] = value as AnyObject
        }
    }

   // 3
   var item: CFTypeRef?

   // 4
   let result = SecItemCopyMatching(query as CFDictionary, &item)

   // 5
   if result != errSecSuccess {
      throw convertError(result)
   }

   // 6
   guard
      let keychainItem = item as? [String : Any],
      let data = keychainItem[kSecValueData as String] as? Data
   else {
      throw KeychainError.invalidData
   }

   // 7
   return try JSONDecoder().decode(T.self, from: data)
}
  1. 쿼리에 kSecReturnAttributeskSecReturnData를 true로 지정한다. 키체인 아이템 생성시 사용했던 키값이 예제 기준으로 kSecAttrAccount였으므로 함수 파라미터의 key도 그대로 사용한다.
  2. 아이템 어트리뷰트 역시 함수 파라미터로부터 가져와 딕셔너리에 추가한다.
  3. SecItemCopyMatching 메서드를 쿼리와 함께 호출하고, 두 번째 파라미터에 서치 결과를 가져온다.
  4. 코드 흐름상 계정 아이디를 기준으로 비밀번호를 불러오기 때문에, kSecMatchLimit은 쿼리에 지정해두지 않았다.
  5. 에러 핸들링 후 가져온 키체인 아이템을 딕셔너리로 타입캐스팅한다.
  6. 타입캐스팅된 딕셔너리 키체인 아이템 기준으로 kSecValueData로 인덱싱하여 sensitive data를 불러온다.
  7. 디코딩하여 해당 데이터를 리턴받는다.

# CRUD - Update

예제코드이다.

func updateItem<T: Encodable>(
    with item: T,
    ofClass itemClass: ItemClass,
    key: String,
    attributes: ItemAttributes? = nil) throws {

        let itemData = try JSONEncoder().encode(item)

        var query: KeychainDictionary = [
        kSecClass as String: itemClass.rawValue,
        kSecAttrAccount as String: key as AnyObject
        ]

        if let itemAttributes = attributes {
            for(key, value) in itemAttributes {
                query[key as String] = value as AnyObject
            }
        }

        let attributesToUpdate: KeychainDictionary = [
        kSecValueData as String: itemData as AnyObject
        ]

        let result = SecItemUpdate(
        query as CFDictionary,
        attributesToUpdate as CFDictionary
        )

        if result != errSecSuccess {
            throw convertError(result)
        }
    }
  1. 업데이트 대상 값을 item파라미터로 받으며 함수 호출시 인코딩하여 변수에 저장해둔다.
  2. 쿼리 딕셔너리를 함수 파라미터 및 나머지 어트리뷰트와 함께 만들어둔다.
  3. 업데이트 필드를 지정하는데 예제상으로는 비밀번호만 변경할 것이므로 kSecValueData에만 접근한다.
  4. SecItemUpdate함수를 호출하고 쿼리와 업데이트 필드를 전달한다.
  5. 에러 핸들링

# CRUD - Delete

func deleteItem(
   ofClass itemClass: ItemClass,
   key: String, attributes:
   ItemAttributes? = nil) throws {

   var query: KeychainDictionary = [
      kSecClass as String: itemClass.rawValue,
      kSecAttrAccount as String: key as AnyObject
   ]

    if let itemAttributes = attributes {
        for(key, value) in itemAttributes {
            query[key as String] = value as AnyObject
        }
    }

   let result = SecItemDelete(query as CFDictionary)
   if result != errSecSuccess {
      throw convertError(result)
   }
}
  1. 쿼리와 아이템 어트리뷰트를 생성하여 딕셔너리를 만든다.
  2. SecItemDelete를 호출하고, 에러를 핸들링한다.

# 활용

아래는 활용을 위한 예제코드이다.

let apiToken = "토큰값"

do {
   let apiTokenAttributes: KeychainManager.ItemAttributes = [
      kSecAttrLabel: "ApiToken"
   ]

   try KeychainManager.shared.saveItem(
      apiToken,
      itemClass: .generic,
      key: "ApiToken",
      attributes: apiTokenAttributes
   )

   let token: String = try KeychainManager.shared.retrieveItem(
      ofClass: .generic,
      key: "ApiToken",
      attributes: apiTokenAttributes
   )

   try KeychainManager.shared.updateItem(
      with: "new-token-value",
      ofClass: .generic,
      key: "ApiToken",
      attributes: apiTokenAttributes
   )

   try KeychainManager.shared.deleteImte(
      ofClass: .generic,
      key: "ApiToken",
      attributes: apiTokenAttributes
   )

} catch let keychainError as KeychainManager.KeychainError {
   print(keychainError.localizedDescription)
} catch {
   print(error)
}
  1. 서버로부터 API 토큰을 가져온다.
  2. ItemAttributes 타입 속성에 원하는 딕셔너리 키를 추가해둔다.
  3. CRUD를 동작시킨다.

# Reference

  1. Medium - keychain services in swift (opens new window)