아래는 미디엄 - 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
함수에 대한 설명이다.
- 파라미터의
itemClass
는 저장할 키체인 데이터의 클래스를 지정한다. 키체인 관련 문서에 정리되어 있지만,kSecClassInternetPassword
등 키체인 데이터에 대한 용도를 지정하게 된다. key
파라미터는 함수 내에서query
변수에서 사용한다.attributes
파라미터는 키체인 아이템에 대한 나머지 키값들을 세팅한다.
함수 내의 로직은 다음과 같다.
- 파라미터로부터 제네릭 형태로
item
을 받는데, 이는 실제 키체인에 저장할sensitive data
에 해당한다. Data타입으로 인코딩해둔다. - 쿼리 변수를 딕셔너리로 생성한다.
CFDictionary
로 사용하지 않고 일반 딕셔너리로 생성해두며 추후 키체인 API를 호출할때CFDictionary
로 타입캐스팅한다. 순서는 상관없지만, nested로 정의해둔 열거형을 활용하려면 전자의 방식을 택하는 것이 좋다. - 쿼리는 아래 값들을 사용한다.
kSecClass
는 함수 파라미터의itemClass
의rawValue
를 받는다. 이는 nested 열거형 타입이다.- 예제에서는
kSecAttrAccount
키값에 대한 밸류로 함수 파라미터key
를 할당하고 있는데, 의미적으로 바라봤을때 계정 ID는 고유하기 때문에 이를 기준으로 키체인에서 패스워드를 가져온다고 볼 수 있겠다. 꼭kSecAttrAccount
키가 아니더라도 다른 키를 사용해볼 수 있다. kSecValueData
키값 밸류에는 인코딩된 데이터를 할당한다.
itemAttributes
는 함수 파라미터attributes
를 순회하며 딕셔너리에 키값과 밸류값들을 할당하게 된다.- CRUD의 Create API이므로
SecItemAdd
함수를 호출한다. 쿼리를CFDictionary
로 타입캐스팅해주고, result는nil
값을 전달한다. 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)
}
- 쿼리에
kSecReturnAttributes
와kSecReturnData
를 true로 지정한다. 키체인 아이템 생성시 사용했던 키값이 예제 기준으로kSecAttrAccount
였으므로 함수 파라미터의 key도 그대로 사용한다. - 아이템 어트리뷰트 역시 함수 파라미터로부터 가져와 딕셔너리에 추가한다.
SecItemCopyMatching
메서드를 쿼리와 함께 호출하고, 두 번째 파라미터에 서치 결과를 가져온다.- 코드 흐름상 계정 아이디를 기준으로 비밀번호를 불러오기 때문에,
kSecMatchLimit
은 쿼리에 지정해두지 않았다. - 에러 핸들링 후 가져온 키체인 아이템을 딕셔너리로 타입캐스팅한다.
- 타입캐스팅된 딕셔너리 키체인 아이템 기준으로
kSecValueData
로 인덱싱하여sensitive data
를 불러온다. - 디코딩하여 해당 데이터를 리턴받는다.
# 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)
}
}
- 업데이트 대상 값을
item
파라미터로 받으며 함수 호출시 인코딩하여 변수에 저장해둔다. - 쿼리 딕셔너리를 함수 파라미터 및 나머지 어트리뷰트와 함께 만들어둔다.
- 업데이트 필드를 지정하는데 예제상으로는 비밀번호만 변경할 것이므로
kSecValueData
에만 접근한다. SecItemUpdate
함수를 호출하고 쿼리와 업데이트 필드를 전달한다.- 에러 핸들링
# 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)
}
}
- 쿼리와 아이템 어트리뷰트를 생성하여 딕셔너리를 만든다.
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)
}
- 서버로부터 API 토큰을 가져온다.
ItemAttributes
타입 속성에 원하는 딕셔너리 키를 추가해둔다.- CRUD를 동작시킨다.