[iOS] Keychain으로 비밀스럽게(?) 데이터를 저장해보자

thinkySide·2024년 4월 6일
3
post-thumbnail

Keychain을 공부하게 된 이유

사이드 프로젝트를 진행하며 서버에서 내려준 accessToken과 refreshToken을 저장해줘야 할 일이 생겼다. 사실 UserDefaults 에 저장해도 괜찮다는 이야기를 듣긴 했으나,,(요렇게 하면 엄청 간단해지긴함) 언젠가는 Keychain이라는 녀석을 써봐야하지 않을까? 라는 생각에 공식문서부터 찬찬히 뜯어보기로 했다.

아래 링크는 AccessToken을 꼭 Keychain에 저장해야하는가? 에 대한 블로그 글인데 저와 같은 고민을 하고 있다면 읽어보시길 추천드립니다!
[iOS] Access Token은 어디에 저장하는게 좋을까? UserDefaults? KeyChain? - 코코종님의 tistory

블로그 글을 어떤식으로 작성해야 내 머릿속에 쏙쏙 들어올까 고민하다 공식문서의 내용을 그대로 옮겨오는 것 만으로는 내 지식이 될 수 없다는 생각이 들었다. (이미 검색만 해도 정말 많이 나오기 때문에 더더욱) 그래서 내가 이해한 방식대로 쉽고 재밌게 풀어서 설명해보자~ 라는 컨셉으로 글을 작성해보려 한다.

🚨 개인적인 해석이 섞여있을 수 있습니다. 꼭 공식문서와 함께해주세요! :)

Keychain 그거 왜 써야 하는건데

우선 공식문서에서 이야기하는 Keychain Services API 에 대해 알아보자.

"사용자를 대신해 작은 데이터를 안전하게 저장합니다."
Keychain Services - Apple Documentation

말 그대로 사용자(우리 개발자들을 말하는걸까?)를 대신해 데이터를 안전하게 저장해주는 고마운 API다. 그럼 여기서 두가지가 궁금해진다.

1. 어떻게(대체 왜?) 사용자 대신 저장하는건데?
2. 어떻게 안전하게 저장하는건데?

하나씩 짚어가며 Keychain을 알아가보자.

1. 어떻게(대체 왜?) 사용자 대신 저장하는건데?

아마 Keychain 사용 방법을 검색하다 이 글을 보게 됐다면 구현 코드가 바로 나오지 않음을 알아차리고 뒤로가기 버튼을 클릭할 수도 있다. 하지만 Keychain을 썼을 때 !뭐가 좋은지!를 알려면 요 부분을 꼭 짚고 넘어가야한다.

우리의 사용자들은 수많은 비밀을 가지고 있다. 물론 여기서 이야기하는 비밀은 연애와 같은 개인사가 아니다. 온라인 계정에 사용하는 비밀번호, 인증서, 개인 메모, 신용 정보 등의 비밀에 대한 이야기다. 이 비밀들은 모두 '안전하게 저장' 되어야만 한다. 안전하게 저장될려면 어떻게 해야할까? 적어도 다음 그림과 해서는 안된다.

위 그림의 비밀번호로 접속할 수 있는 계정이 꽤 될 것이라 장담한다.(내꺼 중에 있을 수도 있다) 이렇듯 기억하기 쉬운(안전하지 못한) 암호를 사용자는 왜 사용을 하는 것일까? 각 서비스마다 복잡하고 고유한 암호를 기억하는 것은 사실 불가능에 가깝기 때문이다. 이를 메모장 등에 남몰래 적어두는 것도 어찌되었든 안전하지 못할 수 있기 때문에 좋은 방법이라 이야기 할 수는 없는 것이다.

2. 어떻게 안전하게 저장하는건데?

애플은 이러한 문제를 해결하기 위해 Keychain Services API를 만들었다. 그리고 요 API가 제공하는 핵심 기능은 다음과 같다.

1. 길고 어려운 암호를 만들어도 내가 대신 기억해줄게!! (넌 기억 안해도 댐. 내가 대신 인증해줌)
2. 암호 탈취가 걱정돼? 요거 우리가 다 알아서 암호화해서 갖고 있을거임 걱정 노노.

비유를 비밀번호(암호)로 들고 있긴 하지만, 위에서 말했듯 암호화 키, 인증서, 비밀 노트, 심지어 필요로하지만 인식하지 못하는 것들까지 모두 암호화해서 저장할 수 있다. 결과적으로 사용자는 쉽고 편리하게 비밀들을 관리할 수 있다는 것!

그리고 추가로 API에서 자동으로 암호화가 필요한 것들에 대한 처리를 해주기 때문에, 개발자 또한 쉽고 편리하게 사용이 가능하다는 것이다!

사용자는 쉽고 편리하게 비밀을 관리하고, 인증할 수 있다!
++ 개발자도 쉽고 편리하게 사용이 가능하다!

Keychain 전체 플로우 살펴보기

지금까지 Keychain이 생겨나게 된 배경과 사용했을 때의 이점을 알아봤다. 그럼 실제 동작하는 플로우는 어떻게 될까? 언제 비밀번호를 받아서 언제 대신 인증해준다는거지? 먼저 가장 일반적인 Keychain의 인증 플로우를 알아보자.

✏️ 이제부터 편한 작성을 위해 위해 비밀번호, 인증서, 개인메모, 금융 정보 등의 비밀을 하나로 묶어 '비밀번호' 로, 인증 과정을 로그인 으로 퉁치겠습니다.

사용자가 로그인(Keychain API를 사용한 무언가)을 시도하면, 암호화된 데이터베이스에서 저장된 비밀번호를 찾아 인증을 받고, 성공하면 로그인이 완료된다. 이미 저장되어있는 비밀번호를 사용하기 때문에 사용자가 귀찮게 다시 입력할 필요가 없는 것이다! 자 그럼 저장된 비밀번호가 없을 때, 즉 처음 로그인을 시도하면 어떻게 될까?

처음 로그인을 시도하게되면 우리에게 익숙한 비밀번호 입력창(혹은 FaceID) 프롬프트가 나타나게 될 것이다. 여기서 인증에 성공하게 되면, DB에 암호화해 저장 후 로그인에 성공하게 되고, 실패하게 되면 귀찮지만 다시 입력하거나 비밀번호를 찾는 먼 여정을 떠나야 할 것이다.

여기서 우리는 한가지 상황을 더 고려해야 한다. 쇼핑 서비스를 사용하는 상황을 가정해보자. 평소에 쇼핑 앱을 즐겨 쓰던 나는 핸드폰을 집에 두고와 회사에 있는 컴퓨터로 로그인 하기로 했다. 앗차,, 보안에 정말 관심이 많던 나(?)는 그 누구도 쉽게 상상하지 못할 비밀번호를 사용했었고 결국 떠올리지 못한 나는 비밀번호를 새로 설정하게 되었다. 다시 집으로 돌아와 쇼핑 앱을 키게 된 바로 이 순간!!! Keychain은 어떻게 동작하게 될까?

기존 값이 달라졌음을 알아차리고 새로운 비밀번호를 요구하는 프롬프트가 나타나게 될 것이다. 새로운 비밀번호를 알맞게 입력했다면 기존에 가지고 있던 비밀번호를 업데이트하고, 로그인에 성공하게 된다.

아래 그림은 애플 공식문서에서 사용한 것으로 위의 내용과 동일하다. 여기서 어떤 함수명으로 Keychain을 사용하는지 중점적으로 보면 좋을 것 같다.

Keychain 사용을 위해 알아야 할 것

드디어 우리는 Keychain의 전체적인 흐름까지 알게되었다. 위 과정을 이해했다면, 우리가 구현을 위해 알아야할게 무엇인지 정리가 된다.

A. Keychain을 저장하는 방법
B. Keychain을 검색하는 방법
C. Keychain을 업데이트하는 방법

앞서 말햇듯 암호화 과정은 전부 Keychain Services API가 대신해주기 때문에 단순 사용을 위해서라면 고려하지 않아도 된다! (물론 애플이 어떤 암호화 방식을 사용하는지 알 방법도 없다)

그리고 실제 구현에 앞서, 내가 Keychian 을 사용하며 느낀 공식(?)을 미리 공유하면 도움이 될까 싶어 먼저 설명해보려 한다.

모든 Keychain 관련 동작(함수)은 3가지만 기억하면 된다!

1. query(요청) 생성하기
2. 요청(함수 호출)하기
3. 결과 확인하기

1. query(요청) 생성하기

가장 먼저 해야할 일은 내가 어떤 Keychain을 저장할지, 검색할지, 업데이트할지, 삭제할지 지정해야하는 것이다. 그렇게 숨길 값을 query에 실어 보내면 되겠지,, 라고 생각하다보면 이 값을 찾을 땐 어떻게 한다는거지? 라는 의문에 도달하게 된다.

이를 위해 우리는 숨길 값만 저장하는 것이 아닌, 식별할 수 있는 attribute 세트를 같이 넣어줘야 한다. 공식문서에 이를 이해하기 쉽게 표현한 그림이 있다.

그리고 어느 요청서에나 양식이 있듯이, Keychain API를 사용할 때도 양식이 필요하다.

  • 첫번째로, 어떤 데이터 타입을 저장하는지 정해주고 = class
  • 두번째로, 그 데이터를 자세히 설명할 수 있는 특성 = attribute

이렇게 두개를 설정해주면 query 준비가 끝난다.

이러한 query[Key:Value] 즉, Dictionary로 구성되어 있다. 또한 Key, Value에 사용할 수 있는 것들을 애플이 미리 제공해주는데, 종류가 정말 많고 다양해서 대표적으로 많이 사용하는 것들만 추려 정리해봤다.

kSecClass: 데이터 타입(클래스)을 나타내는 Key (👑 얘가 제일 중요)

  • kSecClassGenericPassword: 일반 비밀번호 클래스를 나타내는 Value
  • kSecClassInternetPassword: 인터넷 비밀번호 클래스를 나타내는 Value
  • kSecClassCertificate: 인증서 클래스를 나타내는 Value
  • kSecClassIdentity: 신원 클래스를 나타내는 Value
  • kSecClassKey: 암호화 키 클래스를 나타내는 Value

🚨 첫번째 중요 포인트!
kSecClass의 Value에 따라 암호화 방식, 여부가 결정된다! 예를 들어 kSecClassKey, kSecGenericPassword는 암호화가 되어 저장되지만 kSecCertificate의 경우 암호화가 되지 않는다.

kSecValueData: 실제 값(암호화 할 값)을 나타내는 Key

  • CFData 타입은 모두 Value가 될 수 있음

kSecAttrType: item 타입을 나타내는 Key

  • CFNumber 타입은 모두 Value가 될 수 있음

kSecAttrLabel: item 레이블을 나타내는 Key

  • CFString 타입은 모두 Value가 될 수 있음

kSecReturnAttributes: attribute를 반환할지 여부를 나타내는 Key

  • CFBoolean 타입은 모두 Value가 될 수 있음

kSecReturnData: 실제 값을 반환할지 여부를 나타내는 Key

  • CFBoolean 타입은 모두 Value가 될 수 있음

🚨 두번째 중요 포인트!
이밖에도 정말 많은 attribute가 있다. 다만 사용에 주의를 해야할 것이 class에 따라 사용 가능 여부가 결정되기 때문에 특정 attribute를 사용하길 원한다면 공식문서를 꼭 참고하길 바란다.
(나는 이것도 모르고 왜 안되지 삽질했지만 여러분은 안그랬으면 좋겠따!)
Item Class Keys - Apple Documentation

이처럼 Key에 들어갈 수 있는 Value 타입은 정해져있다. 요러한 양식을 맞춰서 query를 만들어야 원하는 동작 구현이 가능하다는 것!

아래 코드는 새로운 Keychain을 생성할 때 만들 수 있는 query다.

// Keychain API에서 제공하는 Key의 타입이 CFString 이기 때문에
// [CFString: Any] Dictionary를 타입으로 사용하고 있다.
let createQuery: [CFString: Any] = [

	// Class
	kSecClass: kSecClassKey, // 암호화되는 타입, kSecClassKey 사용
    
    // Attribute
	kSecAttrLabel: "액세스 토큰", // 키체인 검색용 라벨
	kSecValueData: tokenData // 실제 토큰 값
]

그리고 여기까지 이해했다면 Keychain의 핵심 내용은 모두 이해한 것이나 다름없다.(뇌피셜) 남은 부분은 잘 요청하고, 결과를 받아오기만 하면 된다.

2. 요청(함수)하기

query를 완성했다면 Keychain Services가 만들어둔 함수를 이용해 요청하면 된다. (아래 함수의 첫번째 파라미터로 query가 들어가게 될 것이다.)

  • SecItemAdd(_:_:): Keychain Item 추가
  • SecItemCopyMatching(_:_:): Keychain Item 검색
  • SecItemUpdate(_:_:): Keychain Item 업데이트
  • SecItemDelete(_:): Keychain Item 삭제

3. 결과 확인하기

위의 요청 함수는 모두 OSStatus를 반환한다. 이렇게 반환된 값을 이용해 Keychain 동작이 성공적으로 이루어졌는지 평가 후, 값을 사용하거나 그대로 진행하면 되는 것!

🧐 OSStatus?
Security Framework(보안 프레임워크)의 결과를 평가하는 타입.
대표적으로 많이 사용하게 될 값은 errSecSuccess(에러 없음), errSecItemNotFound(item 찾지 못함) 등이 있다.
Security Framework Result Codes - Apple Documentation

Keychain 사용해보기

이제 정말 코드로 구현해볼 시간이다. 위에서 이야기한 내용에 한가지를 더한(Keychain 삭제) 동작을 코드로 하나씩 구현해보자.

✏️ 여기서부터는 애플 공식 문서 내용 + 토큰을 저장하기 위해 내가 커스텀한 방법을 섞어 실제 코드와 함께 설명해보려 한다.

준비하기

먼저 Keychain API를 사용하면서 발생할 수 있는 에러를 처리하기 위한 Error 열거형을 생성한다.

(나는 항상 에러 처리가 귀찮아서 print로 찍곤 한다,, 하지만 애플 공식 문서를 볼 때마다 느끼는 것은 너무 당연하게도, 이런 Error 처리를 잘해줘야 좋은 코드가 될 수 있다는 것이다. 기본기를 탄탄히 할 수 있도록 노력해보자!)

enum KeyChainError: Error {
    case notFound // 키체인 찾을 수 없음
    case undexpectedData // 예상치 못한 데이터
    case unHandledError(status: OSStatus) // 예외 처리에 실패한 에러
}

(Optional) 추가로 AccessToken과 RefreshToken 두개를 왔다갔다하며 생성, 검색, 업데이트, 삭제하고 싶었기 때문에 Token 종류를 나눌 수 있는 열거형도 생성해줬다.

enum TokenType: String {
    case access = "accessTokenType" // 액세스 토큰
    case refresh = "refreshTokenType" // 리프레쉬 토큰
}

A. Keychain Item 생성

위에서 이야기한 3가지 단계 (query 생성 - 함수 호출 - 결과 확인)에 집중해 코드를 뜯어보면 금방 이해할 수 있다.

여기서 주목해봐야할 부분은, 결과 확인 단계에서 반환된 OSStatus 값이 errSecDuplicateItem(item이 이미 존재함) 일때, 기존 Keychain Item값을 업데이트 해주는 함수 updateToken을 호출해주는 것이다.

상황에 따라 생성, 업데이트 함수를 따로 불러주는 것보다 하나로 사용하는게 더 효과적이라 판단해 이런 방식으로 구현하게 되었다. (물론 Keychain API를 두번 사용하게 되기 때문에 다른 방식으로도 얼마든지 구현이 가능할 것 같다!)

추가로 SecItemAdd(_:_:) 의 두번째 인수는 반환 데이터 값을 받을 수 있는 자리이다. (아래에서 설명할 인아웃을 이용한 CFRefType 값 반환) 일반적으로 생성과 동시에 값을 받아올 일이 잘 없기에(이미 알고 있다는 소리기도 하니까),,, nil로 주게 된다.

  • 공식 문서에서도 일반적인 경우에 nil로 무시한다고 이야기한다.
    (반환 데이터를 원한다면 SecItemCopyMatching 함수가 매우 유사하게 동작한다고 이야기함! >> 이거 쓰라는 얘긴듯)
/// Keychain Item 생성
func createToken(_ type: TokenType, token: String) throws {
    
    // 1. 토큰 문자열을 Data로 변환
    let tokenData = token.data(using: .utf8)!
    
    // 2. 키체인 생성용 query
    let createQuery: [CFString: Any] = [
        kSecClass: kSecClassKey,
        kSecAttrType: type.rawValue,
        kSecValueData: tokenData
    ]
    
    // 3. 키체인에 query를 이용해 추가
    let status = SecItemAdd(createQuery as CFDictionary, nil)
    
    // 4. 키체인 추가가 잘 되었는지 확인
    if status == errSecSuccess {
        print("키체인 생성 성공")
    } else if status == errSecDuplicateItem {
        
        // 4-1. 만약 이미 존재한다면, 기존 키체인 item 업데이트
        // 바로 뒤에서 설명할 Token을 업데이트 해주는 함수
        print("키체인 업데이트 예정")
        try updateToken(type, value: tokenData)
        
    } else {
        print("키체인 생성 실패")
        throw KeyChainError.unHandledError(status: status)
    }
}

B. Keychain Item 업데이트

업데이트 함수에서는 두가지 query를 사용해야 한다. 하나는 기존 키체인 Item을 검색하기 위한 query이고, 다른 하나는 업데이트 할 데이터(혹은 attribute)를 담고 있는 query이다.

그리고 위에서 설명했듯 외부에서는 createToken 함수만 사용하길 원했기 때문에 private 접근 제어자를 사용했다.

/// Keychain Item 업데이트
private func updateToken(_ type: TokenType, value: Data) throws {
    
    // 1. 기존 키체인 item을 찾기 위한 query
    let originalQuery: [CFString: Any] = [
        kSecClass: kSecClassKey,
        kSecAttrType: type.rawValue // createToken() 함수에서 받아온 토큰 타입이 들어간다.
    ]
    
    // 2. 업데이트할 데이터를 담고 있는 query
    let updateQuery: [CFString: Any] = [
        kSecValueData: value // createToken() 함수에서 받아온 토큰 값이 들어간다.
    ]
    
    // 3. 키체인에 query를 이용해 업데이트
    let status = SecItemUpdate(originalQuery as CFDictionary, updateQuery as CFDictionary)
    
    // 4. 키체인 업데이트가 잘 되었는지 확인
    if status == errSecSuccess {
        print("키체인 업데이트 성공")
    } else {
        print("키체인 업데이트 실패")
        throw KeyChainError.unHandledError(status: status)
    }
}

C. Keychain Item 검색 및 반환

검색 및 반환 코드가 가장 복잡하다고 느낄 수 있지만, 이해하고 보면 어려울 것 없다!

첫번째 포인트로 이번에는 데이터를 받아와야 하기 때문에 query를 생성할 때 kSecReturnAttributeskSecReturnData 값을 넣어주는 것이다. (받아와야 하기 때문에 true!)

kSecReturnData 는 실제 값(토큰)을 담고 있기 때문에 필요하고, kSecReturnAttributes는 attribute를 이용해 Dictionary로 변환해야 하기 때문에 받아와줘야 한다.

두번째 포인트는 SecItemCopyMatching 함수의 인아웃 파라미터로 받아오는 CFTypeRef 타입의 변수다. SecItemCopyMatching가 실행되며 query에 맞는 값을 찾게 되면 결과 값을 쏙 넣어주게 될 것이다.

세번째 포인트는 받아온 CFTypeRef 타입의 결과값을 실제 값(토큰)으로 사용하기 위해 변환하는 과정이다.

🧐 CFTypeRef?
Core Foundation 객체에 대한 입력되지 않은 Generic Reference,, 라고 한다. 여러 다형성 함수(polymorphic functions)에서 타입 및 반환 값으로 사용된다. 이 부분은 공부를 더 해봐야 할 듯 하다.
CFTypeRef - Apple Documentation

/// Keychain Item 검색 및 반환
func token(_ type: TokenType) throws -> String {
    
    // 1. 키체인에서 검색할 query
    let searchQuery: [CFString: Any] = [
        kSecClass: kSecClassKey,
        kSecAttrType: type.rawValue,
        kSecReturnAttributes: true,
        kSecReturnData: true
    ]
    
    // 2. 검색 결과를 담을 변수 생성
    var result: CFTypeRef?
    
    // 3. 키체인에 query를 이용한 검색 및 반환
    // inOut 파라미터로 result 변수에 넣어주게 됨.
    let status = SecItemCopyMatching(searchQuery as CFDictionary, &result)
    
    // 4. 검색 결과가 잘 들어왔는지 확인
    guard status != errSecItemNotFound else {
        print("키체인 검색 결과 없음")
        throw KeyChainError.notFound
    }
    
    // 4. 검색 결과가 잘 들어왔는지 확인 22
    guard status == errSecSuccess else {
        print("키체인 검색 실패")
        throw KeyChainError.unHandledError(status: status)
    }
    
    // 5. 검색 결과를 Dictionary로 변환
    guard let existingItem = result as? [String: Any],
          let tokenData = existingItem[kSecValueData as String] as? Data,
          let token = String(data: tokenData, encoding: .utf8)
    else {
        print("예상치 못한 데이터 반환")
        throw KeyChainError.undexpectedData
    }
    
    // 6. 최종 토큰값 반환
    return token
}

D. Keychain Item 삭제

사용자가 서버에서 로그아웃이나 회원탈퇴를 하게 되면 그와 동시에 기존 Keychain Item은 의미가 없어지게 된다. 즉, 삭제를 시켜줘야한다는 것!

위의 동작들을 구현하는데 성공했다면 삭제는 가장 쉬울 것이다.

/// Keychain Item 삭제
func deleteToken(_ type: TokenType) throws {
    
    // 1. 삭제할 Keychain item 검색을 위한 query
    let deleteQuery: [CFString: Any] = [
        kSecClass: kSecClassKey,
        kSecAttrType: type.rawValue
    ]
    
    // 2. 키체인에 query를 이용한 item 삭제
    let status = SecItemDelete(deleteQuery as CFDictionary)
    
    // 3. 결과값 테스트
    guard status == errSecSuccess || status == errSecItemNotFound
    else {
        print("KeychainError.unhandledError")
        throw KeychainError.unhandledError(status: status)
    }
    
    print("키체인 삭제 성공")
}

Keychain 찍먹을 마치며

최근 들어 내가 단순히 기능 구현을 위해 검색하고 넘어갔을 때 아쉬운 부분들이 많았다. 이 정도는 검색해도 나오니까,, 그렇게 중요한 내용이 아니니까,, 라고 스스로 위안하며 공부해왔었는데 돌아봤을 때 또 다시 같은 내용을 찾아봐야하는 무한 굴레에 빠져버린 것이다.

또한 누군가한테 어떤 파트에 대해 '사용해 봤어' 라고 이야기 할 수는 있겠지만 '잘 알아!' 라는 말을 하고 싶었기 때문에 적당한 수준에서 Keychain이라는 주제를 가지고 미약하게나마 파본 것 같다.

처음으로 나만의 기술 블로그를 작성해보며 어려운 점도 많았지만 결과적으로 정말 좋은 경험이었고, 앞으로도 이렇게 공부를 해보려고 한다. 이제는 Keychian이라는 주제가 내 눈앞에 던져젔을 때 어떤 부분을 파고들어야하는지 명확해진 것 같다.

profile
UX 한스푼 넣은 iOS 디발자 한톨 / Apple Developer Academy @POSTECH 3기

0개의 댓글