본문 바로가기
C.W.K.
Stream
Lesson 05 of 06 · published

키체인 항목은 그걸 만든 앱의 것이야

~15 min · bundle-signing, keychain, secrets, acl, security-framework

Level 0번들 열어본 사람
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"CMD+K는 더 이상 안 물어봤어. 근데 설정을 열 때마다 여전히 매번 물어봤어."

Mac에서 비밀은 어디에 두냐면

오래 보관해야 하는 비밀이 있는 앱은 그걸 Security 프레임워크의 SecItem API로 키체인에 저장해. 가족 앱들도 집에 있는 엔진에 접속하려고 PIN을 들고 있어. 파일이나 UserDefaults엔 절대 안 둬. Mac엔 알아둬야 할 키체인이 둘이야. 로그인 키체인(파일 기반)은 항목마다 접근 제어 목록이 있고, 그 목록이 코드 requirement로 특정 앱을 신뢰해. 데이터 보호 키체인(kSecUseDataProtectionKeychain)은 iOS처럼 키체인 접근 그룹 엔타이틀먼트로 접근 범위를 정해. 그래서 서명 안 됐거나 엔타이틀먼트 없는 바이너리는 항목 대신 -34018(errSecMissingEntitlement)을 받아.

다시 서명해도 사라지지 않은 권한 창

키체인을 읽을 때마다 권한을 묻지 않게 하려고, 가족 편집기를 애드혹 서명에서 안정적인 자체 서명 신원으로 옮겼어. 한 경로는 조용해졌는데, 다른 경로(열 때마다 PIN을 다시 읽는 설정 탭)는 읽을 때마다 계속 물어봤어. requirement는 안정적인 걸 확인했고, 코드도 멀쩡했어.

문제는 항목 자체였어. 그 항목들은 옛 애드혹 빌드가 만든 거라서, 접근 목록이 이제 세상에 없는 코드 requirement를 믿고 있었어. 그 항목들 입장에서 새로 서명한 앱은 영원히 남이었던 거지. 빠른 경로가 고쳐진 것처럼 보인 건 첫 잠금 해제 뒤에 세션 토큰을 메모리에 캐시하고 키체인을 다시 안 읽었기 때문이야. "항상 허용"을 누르지 않고 확실하게 고치는 방법은 그 서비스의 항목을 전부 지우고, 서명된 앱이 새로 저장하게 하는 거야. 그러면 그 앱이 항목의 주인이 돼서 묻지 않고 읽어.

지우는 건 각 Mac의 콘솔 세션에서 직접 해야 해. ssh에선 로그인 키체인이 "User interaction is not allowed"라고 대답해. 원격 세션은 GUI 로그인 바깥이라 거기선 키체인이 접근 권한 창을 못 띄우고, ssh에서 키체인을 풀려면 계정 비밀번호를 스크립트에 넘겨야 해. 다음 트랙에서 다룰, ssh로 서명이 실패하는 문제도 같은 이유야.

없음, 잠김, 못 씀은 서로 다른 대답이야

키체인 읽기 실패를 전부 "저장된 PIN 없음"으로 처리하면 문제가 생겨. 키체인이 잠겼을 뿐인데 첫 실행 화면이 뜨고, 엔타이틀먼트가 빠졌을 뿐인데 조용히 초기화돼 버려. 가족의 공유 비밀 저장소는 결과를 셋으로 나눠 돌려주고, 호출하는 쪽은 각각 다르게 보여줘. 없음이면 PIN을 입력하라고 하고, 잠김이면 키체인이 잠겼다고(아니면 세션이 UI를 못 띄운다고) 말하고, 못 씀이면 상태 코드를 보여줘.

Code

없음, 잠김, 못 씀을 구분하는 조회, 그리고 항목의 주인을 다시 정하는 저장·swift
import Foundation
import Security

enum KeychainLookup: Equatable {
    case found(Data)
    case missing                 // errSecItemNotFound: first run, or never saved
    case locked                  // errSecInteractionNotAllowed: locked, or no UI possible (ssh)
    case unavailable(OSStatus)   // anything else, e.g. -34018 missing entitlement
}

enum SparkSecrets {
    static let service = "com.example.spark.engine-pin"

    static func readPIN(account: String) -> KeychainLookup {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: account,
            kSecReturnData as String: true,
            kSecMatchLimit as String: kSecMatchLimitOne,
        ]
        var result: CFTypeRef?
        let status = SecItemCopyMatching(query as CFDictionary, &result)
        switch status {
        case errSecSuccess: return (result as? Data).map(KeychainLookup.found) ?? .unavailable(status)
        case errSecItemNotFound: return .missing
        case errSecInteractionNotAllowed: return .locked
        default: return .unavailable(status)
        }
    }

    static func savePIN(_ pin: String, account: String) -> OSStatus {
        let base: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: account,
        ]
        SecItemDelete(base as CFDictionary)   // the new item is owned by THIS signed build
        var add = base
        add[kSecValueData as String] = Data(pin.utf8)
        return SecItemAdd(add as CFDictionary, nil)
    }
}

External links

Exercise

Spark Mac 앱에 SparkSecrets를 넣어. 애드혹으로 빌드해서 PIN을 저장하고 다시 읽어. 이제 안정적인 자체 서명 신원으로 다시 빌드해서 또 읽어봐. macOS가 물어보는지 적어. security delete-generic-password -s com.example.spark.engine-pin으로 항목을 지운 뒤 서명된 빌드로 저장해. 한 번 더 다시 빌드하고, 이번엔 묻지 않고 읽히는지 확인해. 마지막으로 다른 Mac에서 ssh로 읽기를 돌려서, 세 결과 중 무엇이 돌아오는지 적어.
Hint
지우기는 더 지울 게 없어서 실패할 때까지 여러 번 돌려. 같은 서비스에 계정이 다른 항목이 여러 개 있을 수 있거든. security가 항목을 못 찾았다고 대답하면 다 지운 거야.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.