포스트

StreamDeck (15) 설정 파일에서 키체인으로 옮긴 기기 토큰

이 시리즈는 내가 직접 쓴 개발기가 아니다. 무엇을 만들지 정해 Claude Code 에게 앱을 만들게 했고, 만든 과정과 쓰인 기술은 Claude Code 가 정리했다. 나는 방향을 정하고, 실기기에서 써 보며 고칠 곳을 찾아 다시 맡겼다. 그래도 글로 남기는 건 공부 때문이다. 아직 내가 아는 프레임워크가 몇 개 없어서, 이 앱에 쓰인 여러 프레임워크를 정리해 두고 나중에 하나씩 찾아보며 익히려고 한다.

5편에서 페어링한 기기마다 토큰을 만들었다. 아이패드와 아이폰은 이 토큰을 키체인에 넣었는데, 맥은 허락한 기기 목록과 함께 설정 파일에 그대로 적었다. 기능이 거의 다 갖춰진 뒤 앱의 보안을 다시 훑어보니, 이 파일이 쉽게 고칠 수 있는 약점 중 가장 컸다. 그래서 맥도 토큰을 키체인으로 옮겼다.

  • 설정 파일에는 기기 ID와 이름만 남긴다.
  • 토큰은 맥 로그인 키체인에 기기마다 하나씩 둔다.
  • 설정 파일과 폴더는 나만 읽을 수 있게 좁힌다.

설정 파일에 그대로 있던 토큰

맥 앱의 설정은 ~/Library/Application Support/TouchDeck/config.json 한 파일에 있다. 버튼 배치와 허락한 기기 목록이 같이 들어 있고, 기기 목록은 이런 모양이었다(값은 지어낸 것이다).

1
2
3
4
5
6
7
8
9
"trustedDevices" : [
  {
    "id" : "EB9218ED-E55F-4BC8-9EB5-318877AFC371",
    "name" : "iPad",
    "screenAllowed" : true,
    "token" : "c2VjcmV0LXRva2VuLWZvci1kZW1vLW9ubHktMzJieXRlcw==",
    "unlockAllowed" : false
  }
]

token은 5편의 키 교환으로 만든 32바이트 값이다. 맥과 아이패드가 서로 “그 기기가 맞다”고 증명할 때 이 값을 쓴다. 그래서 이 값을 손에 넣은 사람은 그 아이패드 행세를 할 수 있다. 버튼을 누르고, 화면 보기를 허락한 기기라면 맥 화면도 볼 수 있다.

그런데 이 파일은 같은 맥의 다른 사용자 계정도 읽을 수 있었다. 백업이나 동기화 폴더로 파일이 복사되면 토큰도 같이 따라간다.


파일 권한이란?

맥(그리고 리눅스)의 파일에는 누가 무엇을 할 수 있는지가 붙어 있다. 사람을 세 무리로 나눈다.

무리뜻
주인파일을 만든 사용자. 여기서는 나
그룹주인과 같은 그룹에 묶인 사용자
나머지이 맥의 다른 모든 사용자

무리마다 읽기(r), 쓰기(w), 실행(x)을 켜고 끈다. 터미널의 ls -l로 보면 -rw-r--r--처럼 나오는데, 앞의 -는 파일이라는 표시이고 그 뒤를 셋씩 끊어 읽는다. 주인은 rw-(읽기, 쓰기), 그룹과 나머지는 r--(읽기만)이다.

숫자로도 쓴다. 읽기 4, 쓰기 2, 실행 1을 더해 무리마다 한 자리로 적는다. rw-는 4+2=6, r--는 4라서 위 파일은 644다. 앱이 파일을 새로 만들면 보통 이 644가 된다. 아파트 우편함에 비유하면 주인만 넣고 뺄 수 있지만, 앞면이 유리라서 지나가는 이웃 누구나 안을 들여다볼 수 있는 상태다.

이번에 설정 파일은 600(rw-------), 폴더는 700(rwx------)으로 좁혔다. 주인 말고는 아무것도 못 한다. 폴더의 x는 “안으로 들어가기”라서, 폴더가 700이면 다른 사용자는 안에 무슨 파일이 있는지도 볼 수 없다.


토큰은 맥 로그인 키체인으로

권한을 좁히는 것만으로는 부족하다. 파일이 백업이나 다른 곳으로 복사되면 권한과 상관없이 토큰이 그대로 따라간다. 그래서 토큰 자체를 파일 밖으로 꺼내, 아이패드처럼 키체인에 두었다.

바꾸기 전에는 config.json 안에 기기 ID, 이름, 토큰이 같이 있었고 다른 계정도 읽을 수 있었다. 바꾼 뒤에는 config.json에 기기 ID와 이름만 남고 나만 읽을 수 있으며, 토큰은 맥 로그인 키체인의 기기 ID 이름표 칸으로 옮겨 같은 서명의 TouchDeck만 꺼낸다


맥 로그인 키체인이란?

11편에서 키체인을 애플 기기가 작은 비밀을 암호화해 보관하는 곳이라고 정리했다. 맥에는 사용자마다 로그인 키체인이 하나씩 있다. 맥에 로그인하는 순간 로그인 비밀번호로 열리고, 로그아웃하면 다시 잠긴다. 맥 앱 “키체인 접근”에서 보이는 그 금고다.

로그인 키체인의 칸마다 “묻지 않고 꺼낼 수 있는 앱” 목록이 붙는다. 칸을 만든 앱이 처음부터 이 목록에 들어간다. 맥은 앱을 이름이 아니라 코드 서명으로 알아본다. 그래서 이름만 TouchDeck인 가짜 앱이 꺼내려 하면 맥이 “이 앱에 허용할까요?” 창을 띄운다. 집 열쇠를 맡긴 경비실이 얼굴(서명)을 확인하고 내주는 것과 같다.

TouchDeck 맥 앱은 Debug 빌드와 Release 빌드 모두 같은 개발자 인증서로 서명된다. 맥이 앱을 알아보는 조건(designated requirement)을 찍어 보면 둘이 같다.

1
2
designated => identifier "com.harold.TouchDeckMac" and anchor apple generic
  and certificate leaf[subject.CN] = "Apple Development: ..."

그래서 어느 빌드로 넣은 토큰이든 다른 빌드가 묻지 않고 꺼낸다.

맥에는 아이폰과 같은 방식의 키체인(data protection keychain)도 있다. 다만 이걸 쓰려면 앱에 키체인 권한 목록(entitlement)을 넣어야 한다. TouchDeck 맥 앱은 다른 앱을 켜고 키를 누르느라 샌드박스 없이 돌아서, 그런 설정 없이 바로 쓸 수 있는 로그인 키체인을 골랐다.


키체인을 다루는 작은 도우미

토큰을 넣고 꺼내는 일을 작은 도우미로 감쌌다. 테스트에서는 진짜 키체인 대신 가짜를 끼울 수 있게 protocol(이런 함수를 갖춘 타입이면 된다는 약속)을 먼저 두었다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// DeviceTokenStore.swift (맥)
protocol DeviceTokenStore {
    func token(deviceId: String) -> String?
    func setToken(_ token: String, deviceId: String) throws
    func removeToken(deviceId: String)
}

/// 맥 로그인 키체인에 기기 ID마다 하나씩 둔다. 같은 팀 서명의 TouchDeck만 묻지 않고 꺼낼 수 있다
struct KeychainTokenStore: DeviceTokenStore {
    struct Failure: Error, Equatable {
        let status: OSStatus
    }

    var service = "com.harold.TouchDeckMac.device-token"

    func token(deviceId: String) -> String? {
        var q = query(deviceId)
        q[kSecReturnData as String] = true
        q[kSecMatchLimit as String] = kSecMatchLimitOne
        var result: AnyObject?
        guard SecItemCopyMatching(q as CFDictionary, &result) == errSecSuccess, let data = result as? Data else { return nil }
        return String(data: data, encoding: .utf8)
    }

    func setToken(_ token: String, deviceId: String) throws {
        let data = Data(token.utf8)
        var status = SecItemUpdate(query(deviceId) as CFDictionary, [kSecValueData as String: data] as CFDictionary)
        if status == errSecItemNotFound {
            var q = query(deviceId)
            q[kSecValueData as String] = data
            q[kSecAttrLabel as String] = "TouchDeck 기기 토큰"
            status = SecItemAdd(q as CFDictionary, nil)
        }
        guard status == errSecSuccess else { throw Failure(status: status) }
    }

    func removeToken(deviceId: String) {
        SecItemDelete(query(deviceId) as CFDictionary)
    }

    private func query(_ deviceId: String) -> [String: Any] {
        [kSecClass as String: kSecClassGenericPassword,
         kSecAttrService as String: service,
         kSecAttrAccount as String: deviceId]
    }
}

SecItem 함수로 기기 토큰 넣고 꺼내고 지우기

키체인 함수들은 모두 “어느 칸인지”를 적은 사전(dictionary)을 받는다. query가 그 사전을 만든다.

  1. kSecClassGenericPassword는 “일반 비밀번호” 종류의 칸이라는 뜻이다. 웹사이트 비밀번호나 인증서가 아닌, 앱이 정한 아무 비밀에 쓴다.
  2. 칸은 kSecAttrService와 kSecAttrAccount 두 값으로 찾는다. 서비스는 “TouchDeck 기기 토큰”이라는 서랍 이름, 계정은 그 서랍 안의 기기 ID 이름표다. 기기가 둘이면 같은 서랍에 칸이 둘 생긴다.
  3. 꺼낼 때는 SecItemCopyMatching(_:_:)를 쓴다. kSecReturnData를 켜면 칸에 든 값을 돌려주고, kSecMatchLimitOne은 맞는 칸이 여럿이어도 하나만 달라는 뜻이다. 값은 &result로 받은 자리에 Data로 들어온다.
  4. 넣을 때는 먼저 SecItemUpdate(_:_:)로 이미 있는 칸의 값만 바꿔 본다. 칸이 없으면 errSecItemNotFound가 오고, 그때 SecItemAdd(_:_:)로 새 칸을 만든다. kSecAttrLabel은 키체인 접근 앱에 보이는 이름이다.
  5. 지울 때는 SecItemDelete(_:)를 쓴다.

결과는 모두 OSStatus라는 숫자로 오고, errSecSuccess(0)만 성공이다. 넣기가 실패하면 이 숫자를 담은 Failure를 던져서, 부른 쪽이 파일을 쓰지 않고 멈추게 했다.

11편의 아이폰 쪽은 지우고 다시 넣는 방식이었는데, 여기서는 바꾸기를 먼저 해 본다. 지우고 넣으면 칸이 새로 생기면서 “묻지 않고 꺼낼 수 있는 앱” 목록도 새로 만들어진다. 이미 있는 칸은 값만 바꿔서 그 목록을 그대로 둔다.


키체인 먼저, 파일은 그다음

이제 저장 한 번에 두 곳을 쓴다. 키체인과 설정 파일이다. 둘 중 하나만 써지고 하나가 실패하는 경우를 따져 순서를 정했다.

  • 파일을 먼저 쓰고 키체인이 실패하면, 파일에는 기기가 있는데 토큰은 어디에도 없다. 다음에 켤 때 그 기기는 목록에서 빠지고 다시 페어링해야 한다.
  • 키체인을 먼저 쓰고 실패하면, 파일은 그대로다. 아무것도 바뀌지 않은 채 저장만 실패한다.

그래서 키체인을 먼저 쓰고, 다 되면 파일을 쓴다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// ConfigStore.swift (맥)
/// 키체인에 먼저 쓰고, 다 되면 파일을 쓴다. 파일에만 있고 토큰은 없는 기기가 생기지 않게.
func save(_ config: DeckConfig) throws {
    for device in config.trustedDevices where savedTokens[device.id] != device.token {
        try tokens.setToken(device.token, deviceId: device.id)
        savedTokens[device.id] = device.token
    }
    // 다른 사용자 계정은 폴더도 파일도 못 읽게
    let folder = url.deletingLastPathComponent()
    try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
    try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: folder.path)
    let encoder = JSONEncoder()
    encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
    try encoder.encode(StoredConfig(config)).write(to: url, options: .atomic)
    try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)

    let kept = Set(config.trustedDevices.map(\.id))
    for removed in savedTokens.keys where !kept.contains(removed) {
        tokens.removeToken(deviceId: removed)
        savedTokens[removed] = nil
    }
}

setAttributes로 쓸 때마다 파일 권한 좁히기

write(to:options:)에 .atomic을 주면, 옆에 임시 파일을 다 쓴 뒤 원래 파일과 한 번에 바꿔치기한다. 쓰는 도중에 앱이 꺼져도 반쯤 쓴 파일이 남지 않는다. 대신 매번 새 파일이 생기는 셈이라 권한이 기본값(644)으로 돌아간다. 그래서 쓸 때마다 바로 뒤에 권한을 다시 좁혔다.

setAttributes(_:ofItemAtPath:)는 파일의 속성을 바꾼다. .posixPermissions가 위에서 본 파일 권한 숫자다. Swift에서 0o600의 0o는 8진수라는 표시라서, 권한 숫자를 ls -l과 같은 모양으로 쓸 수 있다.


예전 파일에서 옮기기

이미 페어링해 둔 기기를 다시 페어링하게 만들 수는 없다. 그래서 새 버전이 처음 켜질 때 예전 파일의 토큰을 키체인으로 옮긴다. 파일에 쓰는 모양을 따로 두고, 토큰을 “있을 수도 없을 수도 있는 값”으로 했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// ConfigStore.swift (맥)
/// 파일에 쓰는 모양. token은 예전 파일을 읽을 때만 차 있고, 쓸 때는 비어서 빠진다
private struct StoredConfig: Codable {
    struct Device: Codable {
        var id: String
        var name: String
        var token: String?
        var screenAllowed: Bool?
        var unlockAllowed: Bool?
    }

    var layout: DeckLayout
    var trustedDevices: [Device]

    init(_ config: DeckConfig) {
        layout = config.layout
        trustedDevices = config.trustedDevices.map {
            Device(id: $0.id, name: $0.name, screenAllowed: $0.screenAllowed, unlockAllowed: $0.unlockAllowed)
        }
    }
}

Codable의 Optional로 예전 파일과 새 파일 함께 읽기

Codable을 붙이면 Swift가 JSON으로 쓰고 읽는 코드를 알아서 만들어 준다. 이때 String?처럼 Optional인 속성은 두 가지로 다르게 다룬다. 읽을 때 JSON에 그 키가 없으면 에러 대신 nil로 둔다. 쓸 때 값이 nil이면 그 키를 아예 적지 않는다. 그래서 같은 타입 하나로 토큰이 있는 예전 파일도 읽고, 토큰이 없는 새 파일도 쓴다. 자세한 규칙은 Encoding and Decoding Custom Types Docs에 있다.

앱 안에서 쓰는 TrustedDevice(DeckKit)는 토큰이 꼭 있어야 하는 타입이라 그대로 두었다. 파일 모양만 이 타입으로 따로 뺐다. screenAllowed와 unlockAllowed도 Optional인 까닭은 그 기능이 생기기 전 파일에는 이 키가 없어서다.

읽을 때는 기기마다 파일의 토큰을 먼저 보고, 없으면 키체인에서 찾는다. 어디에도 없는 기기는 목록에서 뺀다. 그 기기가 붙으면 5편처럼 맥이 다시 페어링을 요구한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// ConfigStore.swift (맥)
func load() -> DeckConfig {
    guard let data = try? Data(contentsOf: url),
          let stored = try? JSONDecoder().decode(StoredConfig.self, from: data) else {
        return .empty
    }
    savedTokens = [:]
    let devices = stored.trustedDevices.compactMap { device -> TrustedDevice? in
        if let saved = tokens.token(deviceId: device.id) { savedTokens[device.id] = saved }
        guard let token = device.token ?? savedTokens[device.id] else { return nil }
        return TrustedDevice(id: device.id, name: device.name, token: token,
                             screenAllowed: device.screenAllowed ?? false, unlockAllowed: device.unlockAllowed ?? false)
    }
    let config = DeckConfig(layout: stored.layout, trustedDevices: devices)
    if stored.trustedDevices.contains(where: { $0.token != nil }) {
        // 옮기지 못하면 파일을 그대로 둔다. 다음에 켤 때 다시 옮겨 본다
        do { try save(config) } catch { print("[config] token move failed:", error) }
    }
    return config
}

파일에 토큰이 하나라도 남아 있으면 바로 save를 불러 옮긴다. save가 키체인부터 쓰기 때문에, 옮기기가 실패하면 파일은 토큰을 품은 채 그대로 남는다. 이번에는 파일의 토큰으로 그냥 붙고, 다음에 켤 때 다시 옮겨 본다. 키체인이 안 되는 날에도 다시 페어링할 일은 없다.


허락을 취소한 기기의 토큰

savedTokens는 키체인에 이미 들어 있는 토큰을 기억해 두는 사전이다. 하는 일이 둘이다.

  • 버튼 배치를 바꿀 때도 save가 불린다. 토큰이 그대로인 기기는 키체인에 다시 쓰지 않는다.
  • 맥 메뉴에서 “허락 취소”를 누르면 그 기기가 목록에서 빠진다. 기억해 둔 기기 중 목록에서 사라진 것은 키체인 칸도 지운다. 지우지 않으면 쓰지도 않는 토큰이 키체인에 계속 남는다.

앱을 다시 켠 뒤에 취소해도 지워진다. load가 키체인에서 찾은 토큰을 savedTokens에 채워 두기 때문이다.


테스트

테스트부터 쓰고 실패를 본 뒤 만들었다. 대부분은 진짜 키체인 대신 사전 하나로 된 가짜 DeviceTokenStore를 끼워, 키체인 쓰기가 실패하는 경우도 일부러 만들었다. 진짜 키체인은 테스트마다 서비스 이름을 새로 지어(com.harold.TouchDeckMac.test-<UUID>) 실제 토큰 칸을 건드리지 않게 했다. DeckKit 241개, 맥 84개가 통과한다.

무엇테스트
파일에 토큰 없음savedFileHasNoToken
다시 읽으면 토큰이 돌아옴loadPutsTokenBackFromStore
예전 파일 옮기기oldFileTokenMovesToStore
옮기기 실패oldFileStaysWhenStoreFails, saveDoesNotWriteFileWhenStoreFails
토큰 잃은 기기deviceWithoutTokenIsDropped
허락 취소removedDeviceTokenIsDeleted, removedDeviceTokenIsDeletedAfterRestart
파일 권한fileIsReadableOnlyByOwner
진짜 키체인keychainStoreRoundTrip

맥 테스트는 맥 앱 안에서 돈다(테스트가 앱을 먼저 켜고 그 안에서 실행된다). 그래서 테스트를 돌리는 순간 앱이 켜지면서 이 맥의 진짜 config.json도 옮겨졌다. 덕분에 실제 파일로 결과를 확인할 수 있었다. 허락해 둔 두 기기(아이패드, 아이폰)의 토큰이 키체인에 들어갔고, 파일과 폴더 권한이 바뀌었다. 파일에서 "token"을 찾으면 0개가 나온다.

1
2
3
$ stat -f '%Sp %N' ~/Library/Application\ Support/TouchDeck ~/Library/Application\ Support/TouchDeck/config.json
drwx------ /Users/dongik/Library/Application Support/TouchDeck
-rw------- /Users/dongik/Library/Application Support/TouchDeck/config.json

이렇게 옮겨진 파일은 예전 맥 앱이 읽지 못한다. 예전 앱은 기기마다 토큰이 꼭 있어야 하는데 파일에 없으니, 파일 전체를 못 읽고 빈 설정으로 켜진다. 버튼 배치가 사라진 것처럼 보이고, 그 상태로 무언가 저장하면 진짜로 덮어쓴다. 그래서 이 버전부터는 예전 맥 앱으로 되돌리지 않는다.


남은 한계

이번 변경으로 막지 못하는 것도 적어 둔다.

  • 이미 복사된 예전 파일에는 토큰이 남아 있다. Time Machine 백업 같은 곳이다. 이게 걱정되면 기기를 “허락 취소”한 뒤 다시 페어링하면 된다. 5편의 키 교환으로 새 토큰이 생기고, 예전 토큰은 쓸모가 없어진다.
  • 로그인 키체인은 내가 로그인해 있는 동안 열려 있다. 다른 앱이 꺼내려 하면 맥이 묻기는 하지만, 그 창에서 “허용”을 누르면 꺼내 간다. 모르는 앱이 TouchDeck 기기 토큰을 달라고 하면 거절해야 한다.
  • 5편에서 남겨 둔 “처음 페어링하는 순간에 가짜 맥이 끼는 경우”는 이번 일과 상관없이 그대로다.

참고자료

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.