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이면 다른 사용자는 안에 무슨 파일이 있는지도 볼 수 없다.
토큰은 맥 로그인 키체인으로
권한을 좁히는 것만으로는 부족하다. 파일이 백업이나 다른 곳으로 복사되면 권한과 상관없이 토큰이 그대로 따라간다. 그래서 토큰 자체를 파일 밖으로 꺼내, 아이패드처럼 키체인에 두었다.
맥 로그인 키체인이란?
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가 그 사전을 만든다.
kSecClassGenericPassword는 “일반 비밀번호” 종류의 칸이라는 뜻이다. 웹사이트 비밀번호나 인증서가 아닌, 앱이 정한 아무 비밀에 쓴다.- 칸은
kSecAttrService와kSecAttrAccount두 값으로 찾는다. 서비스는 “TouchDeck 기기 토큰”이라는 서랍 이름, 계정은 그 서랍 안의 기기 ID 이름표다. 기기가 둘이면 같은 서랍에 칸이 둘 생긴다. - 꺼낼 때는
SecItemCopyMatching(_:_:)를 쓴다.kSecReturnData를 켜면 칸에 든 값을 돌려주고,kSecMatchLimitOne은 맞는 칸이 여럿이어도 하나만 달라는 뜻이다. 값은&result로 받은 자리에Data로 들어온다. - 넣을 때는 먼저
SecItemUpdate(_:_:)로 이미 있는 칸의 값만 바꿔 본다. 칸이 없으면errSecItemNotFound가 오고, 그때SecItemAdd(_:_:)로 새 칸을 만든다.kSecAttrLabel은 키체인 접근 앱에 보이는 이름이다. - 지울 때는
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편에서 남겨 둔 “처음 페어링하는 순간에 가짜 맥이 끼는 경우”는 이번 일과 상관없이 그대로다.
