~15 min · universal-ios, data-protection, security, files, background
Level 0번들 열어본 사람
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"폰이 잠겨 있는 동안엔 앱이 자기 파일도 못 읽어. 그래서 백그라운드 비우기는 먼저 확인해야 해."
파일마다 고르는 암호화
iOS 앱이 쓰는 파일은 전부 암호화되고, 그 파일을 읽을 키를 언제 쓸 수 있는지는 보호 등급이 정해. 기본값으로 새 파일은 completeUntilFirstUserAuthentication을 받아. 부팅부터 첫 잠금 해제까지 봉인돼 있다가, 그 뒤론 폰이 켜져 있으면 잠겨 있든 말든 읽을 수 있어. 대부분의 데이터엔 그게 맞는 기본값이야. 건강 기록, 처방전, 검진 PDF, 아니면 글 쓰는 사람의 개인 노트를 가진 앱은 대신 complete를 고를 수 있어. 기기 잠금이 풀려 있을 때만 읽히는 등급이야. 훈련 앱은 기록과 공유 받은 편지함에 이걸 골랐고, 글쓰기 편집기는 기기 서재에 골랐어.
대가는 발견하는 게 아니라 설계하는 거야
complete는 앱 자신한테도 적용돼. 폰이 잠겨 있으면 네 코드도 그 파일을 못 열어. 그래서 동기화, 아웃박스 비우기, 워치에서 도착한 전송처럼 백그라운드에서 도는 일이 파일을 읽을 수 있다고 가정하면 I/O 에러를 만나. 설계는 세 가지로 이뤄져. 백그라운드 비우기는 파일을 읽을 때마다 먼저 UIApplication.shared.isProtectedDataAvailable을 봐. 보내는 걸 기다리는 사이에도 폰이 잠길 수 있거든. 그리고 "잠금 해제 기다리는 중"을 에러가 아니라 상태로 보고해. 앱은 protectedDataDidBecomeAvailableNotification을 듣고 있다가 그때 다시 시작해. 그리고 백그라운드로 배달된 담기처럼 폰이 잠겨 있을 때 만들어야 할 수도 있는 파일엔 completeUnlessOpen을 써. 잠긴 동안에도 만들 수 있고, 닫히면 봉인돼.
증명엔 기기가 필요해
시뮬레이터는 Data Protection을 구현하지 않아. .completeFileProtection으로 쓰면 성공하는데 등급은 기록되지 않고, attributesOfItem은 보호 키를 안 돌려주고, 에러는 어디에도 없어. 처음에 훈련 앱 테스트는 이걸 쓰기 경로가 망가진 걸로 오해했어. 증명 트랙에서 본 가족의 답은 검사를 나누는 거야. 정책 상수는 어디서나 확인하고, 디스크에 실제로 기록된 등급은 기기에서만 확인해. 시뮬레이터에선 이유를 밝히고 건너뛰고. 케이블로 폰 데이터를 읽다가 재본 사실도 하나 관련이 있어. 기기 컨테이너 복사는 열 수 없는 파일을 조용히 빼먹어서, complete로 보호된 편지함은 폰엔 있는데 사본엔 없을 수 있어.
공유 컨테이너도 같은 규칙을 따라
앱과 확장이 같이 쓰는 App Group 컨테이너도 결국 파일을 두는 곳일 뿐이야. 보호 등급은 파일을 기록하는 쪽이 기록할 때마다 각자 정해. 파일 종류마다 등급을 한 번 정해서 저장소 옆에 적어두고, 앱과 거기 쓰는 모든 확장에서 같은 옵션을 써.
Code
네 등급, 그리고 고르는 법·text
class readable while locked? created while locked?
--------------------------------------- ------------------------------ ---------------------
.none yes yes
.completeUntilFirstUserAuthentication yes, after the first unlock yes (the default)
.completeUnlessOpen only if it was already open yes
.complete no no
Choose by what the file holds and when the app must touch it:
health records, a writer's notes, a share inbox -> .complete, and drain only when unlocked
a capture that arrives in the background -> .completeUnlessOpen, so it can be written
caches that are cheap to rebuild -> the default
실패하는 대신 잠금 해제를 기다리는 complete 보호 아웃박스·swift
import Foundation
import UIKit
enum DrainStatus: Equatable, Sendable {
case drained(Int)
case waitingForUnlock // not an error: the files are there, just sealed
}
@MainActor
final class ProtectedOutbox {
private let folder: URL
init(folder: URL) {
self.folder = folder
}
/// Complete: unreadable while the device is locked, even by this app.
func save(_ data: Data, named name: String) throws {
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
try data.write(to: folder.appending(path: name), options: [.atomic, .completeFileProtection])
}
/// A background drain checks before every read, and says what it is waiting for instead of
/// throwing I/O errors. The phone can lock while a send is suspended at the await.
func drain(send: (Data) async throws -> Void) async throws -> DrainStatus {
guard UIApplication.shared.isProtectedDataAvailable else { return .waitingForUnlock }
let files = try FileManager.default.contentsOfDirectory(at: folder, includingPropertiesForKeys: nil)
var sent = 0
for file in files.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) {
guard UIApplication.shared.isProtectedDataAvailable else { return .waitingForUnlock }
let data: Data
do {
data = try Data(contentsOf: file)
} catch where !UIApplication.shared.isProtectedDataAvailable {
return .waitingForUnlock // locked between the check and the read
}
try await send(data)
try FileManager.default.removeItem(at: file)
sent += 1
}
return .drained(sent)
}
/// Resume the moment the device unlocks.
func onUnlock(_ resume: @escaping @MainActor () -> Void) -> NSObjectProtocol {
NotificationCenter.default.addObserver(
forName: UIApplication.protectedDataDidBecomeAvailableNotification,
object: nil, queue: .main
) { _ in
MainActor.assumeIsolated { resume() }
}
}
}
SparkMobile에 ProtectedOutbox를 넣고 항목 하나를 저장해. 실제 아이폰에서 백그라운드 작업으로 비우기를 걸어두고, 돌기 전에 폰을 잠가서 돌려주는 상태를 적어. 잠금을 풀고 관찰자가 다시 시작하는지 확인해. 이어서 기기 전용 테스트를 더해. .completeFileProtection으로 파일을 쓰고 attributesOfItem으로 기록된 등급을 확인하되, 시뮬레이터에선 이유를 남기고 건너뛰게 해.
Hint
isProtectedDataAvailable은 화면이 잠기는 순간 바로 false가 되진 않으니까, 비우기를 시작하기 전에 조금 기다려. 등급은 try FileManager.default.attributesOfItem(atPath:)[.protectionKey] as? FileProtectionType으로 읽어.
Progress
Progress is local-only — sign in to sync across devices.