import Foundation /// 凭据存储抽象:meta 与机密分离。 /// 让「未签名文件后端」与「签名后 Keychain 后端」可无缝替换——列表只用 `list()`(纯 meta), /// 机密经 `secret(for:)` 按需单条取(Keychain 后端天然 meta 在 JSON、机密在 SecItem)。 @MainActor protocol CredentialStore: AnyObject { func list() -> [Credential] func secret(for id: UUID) -> CredentialSecret? func upsert(_ cred: Credential, secret: CredentialSecret?) // secret == nil 时仅改 meta func delete(_ id: UUID) } /// 未签名构建的文件后端。 /// /// 为何不自造加密:无根信任(无 Keychain/SE 兜底存主密钥)则本地对称加密是伪安全—— /// 能读机密文件者同样能读密钥文件。威胁模型为开发自用工具 / 模拟器 / 非越狱设备, /// 沙盒明文与现有 host 明文密码一致即可。做三件几乎免费的加固: /// 0600 权限、`isExcludedFromBackup`(防机密进 iCloud/iTunes 备份)、`FileProtectionType.complete`(锁屏加密)。 /// 信封版本化(`scheme: "plaintext-v1"`)便于签名后按 scheme 分派迁移。 @MainActor final class FileCredentialStore: ObservableObject, CredentialStore { @Published private(set) var credentials: [Credential] = [] private let url: URL private var secrets: [UUID: CredentialSecret] = [:] private struct Entry: Codable { var meta: Credential; var secret: CredentialSecret } private struct Envelope: Codable { var scheme: String; var entries: [Entry] } private static let scheme = "plaintext-v1" init() { let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] try? FileManager.default.createDirectory(at: base, withIntermediateDirectories: true) url = base.appendingPathComponent("credentials.json") load() } func list() -> [Credential] { credentials } func secret(for id: UUID) -> CredentialSecret? { secrets[id] } func upsert(_ cred: Credential, secret: CredentialSecret?) { if let i = credentials.firstIndex(where: { $0.id == cred.id }) { credentials[i] = cred } else { credentials.append(cred) } if let secret { secrets[cred.id] = secret } persist() } func delete(_ id: UUID) { credentials.removeAll { $0.id == id } secrets[id] = nil persist() } // MARK: - 持久化 private func load() { guard let data = try? Data(contentsOf: url), let env = try? JSONDecoder().decode(Envelope.self, from: data) else { return } credentials = env.entries.map(\.meta) secrets = Dictionary(uniqueKeysWithValues: env.entries.map { ($0.meta.id, $0.secret) }) } private func persist() { let entries = credentials.map { Entry(meta: $0, secret: secrets[$0.id] ?? .none) } let env = Envelope(scheme: Self.scheme, entries: entries) guard let data = try? JSONEncoder().encode(env) else { return } try? data.write(to: url, options: [.atomic, .completeFileProtection]) try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) var u = url var rv = URLResourceValues(); rv.isExcludedFromBackup = true try? u.setResourceValues(rv) } }