Files

145 lines
6.4 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import CryptoKit
import Foundation
import Security
import TermindApplication
import TermindDomain
import struct TermindDomain.Host
/// One device-local store, independent of SwiftData/Vault/CloudKit. Leases fence saves racing an explicit close.
public actor TerminalRecoveryStore {
public static let shared = TerminalRecoveryStore()
private let directory: URL
private let testingKey: Data?
private var owners: [UUID: UUID] = [:]
private struct Envelope: Codable {
let metadata: Data
let ciphertext: Data
}
public init(directory: URL? = nil, testingKey: Data? = nil) {
self.directory =
directory
?? URL.applicationSupportDirectory
.appendingPathComponent("Termind/TerminalRecovery", isDirectory: true)
self.testingKey = testingKey
}
public func register(_ id: UUID) -> UUID {
let lease = UUID()
owners[id] = lease
return lease
}
public func record(_ id: UUID) throws -> TerminalRecoveryRecord {
let envelope = try JSONDecoder().decode(Envelope.self, from: Data(contentsOf: location(id)))
_ = try AES.GCM.open(
AES.GCM.SealedBox(combined: envelope.ciphertext), using: key(), authenticating: envelope.metadata)
let record = try JSONDecoder().decode(TerminalRecoveryRecord.self, from: envelope.metadata)
guard record.id == id else { throw TerminalTransportError.invalidCheckpoint }
return record
}
public func records() throws -> [TerminalRecoveryRecord] {
guard FileManager.default.fileExists(atPath: directory.path) else { return [] }
return try FileManager.default.contentsOfDirectory(
at: directory, includingPropertiesForKeys: nil
)
.filter { $0.pathExtension == "terminal" }
.map {
let envelope = try JSONDecoder().decode(Envelope.self, from: Data(contentsOf: $0))
return try JSONDecoder().decode(TerminalRecoveryRecord.self, from: envelope.metadata)
}
.sorted { $0.savedAt > $1.savedAt }
}
public func save(
_ record: TerminalRecoveryRecord, checkpoint: TerminalTransportCheckpoint, lease: UUID
) throws {
guard owners[record.id] == lease, checkpoint.transport == record.transport else {
throw CancellationError()
}
let metadata = try JSONEncoder().encode(record)
let payload = try JSONEncoder().encode(checkpoint)
let ciphertext = try AES.GCM.seal(payload, using: key(), authenticating: metadata).combined!
try FileManager.default.createDirectory(
at: directory, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700])
var excluded = directory
var resources = URLResourceValues()
resources.isExcludedFromBackup = true
try excluded.setResourceValues(resources)
try JSONEncoder().encode(Envelope(metadata: metadata, ciphertext: ciphertext))
.write(to: location(record.id), options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication])
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: location(record.id).path)
}
/// Removes eligibility before returning protocol counters to a caller that can transmit.
public func consume(_ id: UUID, host: Host) throws -> TerminalTransportCheckpoint {
let envelope = try JSONDecoder().decode(Envelope.self, from: Data(contentsOf: location(id)))
let payload = try AES.GCM.open(
AES.GCM.SealedBox(combined: envelope.ciphertext), using: key(), authenticating: envelope.metadata)
let record = try JSONDecoder().decode(TerminalRecoveryRecord.self, from: envelope.metadata)
guard record.id == id, record.matches(host) else { throw TerminalTransportError.invalidCheckpoint }
let checkpoint = try JSONDecoder().decode(TerminalTransportCheckpoint.self, from: payload)
guard checkpoint.version == 1, checkpoint.transport == record.transport else {
throw TerminalTransportError.invalidCheckpoint
}
try remove(id)
return checkpoint
}
public func invalidate(_ id: UUID) throws { try remove(id) }
public func discard(_ id: UUID) throws {
owners.removeValue(forKey: id)
try remove(id)
}
private func remove(_ id: UUID) throws {
let path = location(id)
if FileManager.default.fileExists(atPath: path.path) { try FileManager.default.removeItem(at: path) }
}
private func location(_ id: UUID) -> URL {
directory.appendingPathComponent(id.uuidString.lowercased()).appendingPathExtension("terminal")
}
private func key() throws -> SymmetricKey {
if let testingKey { return SymmetricKey(data: testingKey) }
let match: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.akinokaede.termind.terminal-recovery",
kSecAttrAccount as String: "device-key-v1",
kSecAttrSynchronizable as String: false,
]
var query = match
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecSuccess, let data = result as? Data { return SymmetricKey(data: data) }
guard status == errSecItemNotFound else {
throw NSError(domain: NSOSStatusErrorDomain, code: Int(status))
}
// A missing key must not silently turn existing checkpoints into unrecoverable ciphertext.
if FileManager.default.fileExists(atPath: directory.path),
try FileManager.default.contentsOfDirectory(atPath: directory.path).contains(where: {
$0.hasSuffix(".terminal")
})
{
throw TerminalTransportError.invalidCheckpoint
}
let key = SymmetricKey(size: .bits256)
var add = match
add[kSecValueData as String] = key.withUnsafeBytes { Data($0) }
add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
let inserted = SecItemAdd(add as CFDictionary, nil)
guard inserted == errSecSuccess else { throw NSError(domain: NSOSStatusErrorDomain, code: Int(inserted)) }
return key
}
}