Files
ShadowsocksX-NG/ShadowsocksX-NG2/Domain/Credentials/EncryptedFileStorage.swift
T
Qiu Yuzhou f9e95474e5 refactor: adopt age configuration storage
Replace the custom encrypted container with AgeSwift 0.1.0 so standard
age tools can inspect sensitive configuration independently of the app.

Use independent Keychain X25519 identities for catalog.age and
proxy-listening.age. Preserve atomic commits and confirmed recovery;
reject historical NG2 formats without migration or cleanup.
2026-10-21 14:31:32 +08:00

170 lines
5.9 KiB
Swift

import AgeSwift
import Foundation
enum EncryptedFileError: Error, Equatable {
case malformedFile
case unsupportedVersion
case authenticationFailed
case keyUnavailable
case notLoaded
}
/// age 文件的密钥选择与原子提交;每个实例独立持有已匹配的 identity。
final class EncryptedFileStorage<Value> {
private let keys: MasterKeyStoring
private var currentIdentity: AgeIdentity?
private var loaded = false
private let maximumSize: Int
private let legacyFileNames: [String]
private let makeDefault: () -> Value
private let encode: (Value) throws -> Data
private let decode: (Data) throws -> Value
var isLoaded: Bool { loaded }
// 为 age 分块标签、文件头和 armor 换行预留空间;明文仍单独检查原有上限。
private var maximumFileSize: Int {
let binary = maximumSize + (maximumSize / 65536 + 1) * 16 + 16384
let base64 = (binary + 2) / 3 * 4
return base64 + base64 / 64 * 2 + 1024
}
init(
keys: MasterKeyStoring, maximumSize: Int,
legacyFileNames: [String], makeDefault: @escaping () -> Value,
encode: @escaping (Value) throws -> Data, decode: @escaping (Data) throws -> Value
) {
self.keys = keys
self.maximumSize = maximumSize
self.legacyFileNames = legacyFileNames
self.makeDefault = makeDefault
self.encode = encode
self.decode = decode
}
func load(at url: URL, allowingInteraction: Bool) throws -> Value {
if loaded, !FileManager.default.fileExists(atPath: url.path) {
throw EncryptedFileError.notLoaded
}
if FileManager.default.fileExists(atPath: url.path) {
let (document, identity) = try read(at: url, allowingInteraction: allowingInteraction)
currentIdentity = identity
loaded = true
return document
}
let directory = url.deletingLastPathComponent()
guard
!legacyFileNames.contains(where: {
FileManager.default.fileExists(atPath: directory.appendingPathComponent($0).path)
})
else { throw EncryptedFileError.unsupportedVersion }
let document = makeDefault()
let identity = try createIdentity(allowingInteraction: allowingInteraction)
try write(document, identity: identity, to: url)
currentIdentity = identity
loaded = true
return document
}
func save(_ document: Value, at url: URL) throws {
guard loaded, let identity = currentIdentity else { throw EncryptedFileError.notLoaded }
try write(document, identity: identity, to: url)
}
/// 调用方先取得用户的破坏性操作确认;成功写盘前不发布新状态。
func reset(at url: URL) throws -> Value {
let identity = try createIdentity(allowingInteraction: true)
let document = makeDefault()
try write(document, identity: identity, to: url)
currentIdentity = identity
loaded = true
return document
}
private func createIdentity(allowingInteraction: Bool) throws -> AgeIdentity {
let id = UUID()
let identity = try AgeIdentity.generate()
let data = Data(identity.serialized.utf8)
try keys.create(data, id: id, allowingInteraction: allowingInteraction)
guard try keys.read(id, allowingInteraction: allowingInteraction) == data else {
throw EncryptedFileError.keyUnavailable
}
return identity
}
private func write(_ document: Value, identity: AgeIdentity, to url: URL) throws {
let payload = try encode(document)
guard payload.count <= maximumSize else { throw EncryptedFileError.malformedFile }
let ciphertext = try Age.encrypt(payload, recipients: [identity.recipient])
try AtomicFileWriter.write(ciphertext, to: url)
}
private func read(at url: URL, allowingInteraction: Bool) throws -> (Value, AgeIdentity) {
let data = try readBoundedFile(at: url)
if data.starts(with: Data("SSXNG2".utf8)) {
throw EncryptedFileError.unsupportedVersion
}
if let identity = currentIdentity {
return (try decodePayload(data, identity: identity), identity)
}
var accessError: (any Error)?
var triedIdentity = false
for id in try keys.ids() {
let identity: AgeIdentity
do {
guard let keyData = try keys.read(id, allowingInteraction: allowingInteraction),
let text = String(data: keyData, encoding: .utf8)
else { continue }
identity = try AgeIdentity(string: text)
} catch {
if accessError == nil { accessError = error }
continue
}
triedIdentity = true
let plain: Data
do {
plain = try Age.decrypt(data, identities: [identity])
} catch AgeError.noMatchingKey {
continue
} catch AgeError.invalidFormat {
throw EncryptedFileError.malformedFile
} catch {
throw EncryptedFileError.authenticationFailed
}
return (try decodeCheckedPayload(plain), identity)
}
if let accessError { throw accessError }
throw triedIdentity ? EncryptedFileError.authenticationFailed : .keyUnavailable
}
private func decodePayload(_ data: Data, identity: AgeIdentity) throws -> Value {
let plain: Data
do {
plain = try Age.decrypt(data, identities: [identity])
} catch AgeError.invalidFormat {
throw EncryptedFileError.malformedFile
} catch {
throw EncryptedFileError.authenticationFailed
}
return try decodeCheckedPayload(plain)
}
private func decodeCheckedPayload(_ plain: Data) throws -> Value {
guard plain.count <= maximumSize else { throw EncryptedFileError.malformedFile }
return try decode(plain)
}
private func readBoundedFile(at url: URL) throws -> Data {
let handle = try FileHandle(forReadingFrom: url)
defer { try? handle.close() }
var data = Data()
while let chunk = try handle.read(upToCount: min(65536, maximumFileSize + 1 - data.count)),
!chunk.isEmpty
{
data.append(chunk)
guard data.count <= maximumFileSize else { throw EncryptedFileError.malformedFile }
}
guard !data.isEmpty else { throw EncryptedFileError.malformedFile }
return data
}
}