mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-10-30 14:13:50 +00:00
170 lines
5.9 KiB
Swift
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: CredentialKeyStoring
|
|
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: CredentialKeyStoring, 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
|
|
}
|
|
}
|