Secure Settings store (#831)

* Add SettingsStore

* Use keychain instead of UserDefaults in JustUpdatedChecker.swift

* Add ability disable the SSH comment via settings

* Make SettingsStore functions non-static, re-implement them as
subscripts and make settingsStore an environmentObject

Fixes: https://github.com/maxgoedjen/secretive/pull/536#discussion_r1508446459

* Add rawValues for CommentStyle enum

* Use SettingsStore for querying the comment style in SecretDetailView

Fixes: https://github.com/maxgoedjen/secretive/pull/536#discussion_r1509655340

* Revert "Use keychain instead of UserDefaults in JustUpdatedChecker.swift"

This reverts commit ae8a21a1bf7f9e2d04fcb83c4543995db0111325.

* Remove copyright info

* Use "enum Constants" in SettingsStore

* WIP

* Cleanup

* Cleanup

* Cleanup strings

* WIP

* Cleanup

* Cleanup

* Cleanup

* Cleanup

* Cleanup

---------

Co-authored-by: Paul Heidekrüger <paul.heidekrueger@tum.de>
This commit is contained in:
Max Goedjen
2026-09-07 19:35:25 -07:00
committed by GitHub
parent 59aca9327f
commit 0a4f15e789
6 changed files with 176 additions and 4 deletions

View File

@@ -22,6 +22,9 @@ let package = Package(
.library(
name: "CertificateKit",
targets: ["CertificateKit"]),
.library(
name: "SettingsKit",
targets: ["SettingsKit"]),
.library(
name: "SecretAgentKit",
targets: ["SecretAgentKit"]),
@@ -76,6 +79,12 @@ let package = Package(
resources: [localization],
swiftSettings: swiftSettings,
),
.target(
name: "SettingsKit",
dependencies: [],
resources: [localization],
swiftSettings: swiftSettings,
),
.target(
name: "SecretAgentKit",
dependencies: ["SecretKit", "SSHProtocolKit", "CertificateKit", "Common", "Formatters"],

View File

@@ -0,0 +1,14 @@
import Foundation
struct RequireDestinationInformationSettingsKey: SettingsStore.SettingsKey {
static let defaultValue: Bool = true
}
extension SettingsStore {
public var requireDestinationInformation: Bool {
get { self[RequireDestinationInformationSettingsKey.self] }
set { self[RequireDestinationInformationSettingsKey.self] = newValue }
}
}

View File

@@ -0,0 +1,114 @@
import Foundation
import Observation
import Security
import OSLog
// Setting store backed by macOS keychain for stronger guarantees around ownership/other-process-modification than UserDefaults offers.
@Observable @MainActor public final class SettingsStore: Sendable {
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.settings", category: "SettingsStore")
public init() {
}
private var state: [ObjectIdentifier: UUID] = [:]
subscript<SettingsKeyType: SettingsKey>(_ key: SettingsKeyType.Type) -> SettingsKeyType.Value {
get {
_ = state[ObjectIdentifier(key)]
let queryAttributes = KeychainDictionary([
kSecClass: Constants.keyClass,
kSecAttrService: Constants.keyTag,
kSecAttrAccount: String(describing: SettingsKeyType.self),
kSecUseDataProtectionKeychain: true,
kSecReturnData: true,
kSecReturnAttributes: true,
kSecMatchLimit: kSecMatchLimitOne,
])
var untyped: CFTypeRef?
unsafe SecItemCopyMatching(queryAttributes, &untyped)
guard let typed = untyped as? [CFString: Any] else { return SettingsKeyType.defaultValue }
let decoder = JSONDecoder()
guard let data = typed[kSecValueData] as? Data else { return SettingsKeyType.defaultValue }
return (try? decoder.decode(SettingValue<SettingsKeyType.Value>.self, from: data).value) ?? SettingsKeyType.defaultValue
}
set {
do {
let data = try JSONEncoder().encode(SettingValue(value: newValue))
let keychainAttributes = KeychainDictionary([
kSecClass: Constants.keyClass,
kSecAttrService: Constants.keyTag,
kSecAttrAccount: String(describing: SettingsKeyType.self),
kSecUseDataProtectionKeychain: true,
kSecAttrAccessible: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
kSecValueData: data,
])
let status = SecItemAdd(keychainAttributes, nil)
switch status {
case errSecSuccess:
break
case errSecDuplicateItem:
let updateQuery = KeychainDictionary([
kSecClass: Constants.keyClass,
kSecAttrService: Constants.keyTag,
kSecAttrAccount: String(describing: SettingsKeyType.self),
])
let updatedAttributes = KeychainDictionary([
kSecValueData: data,
])
let status = SecItemUpdate(updateQuery, updatedAttributes)
if status != errSecSuccess {
throw KeychainError(statusCode: status)
}
default:
throw KeychainError(statusCode: status)
}
state[ObjectIdentifier(key)] = UUID()
} catch {
logger.error("Error updating key: \(String(describing: SettingsKeyType.self), privacy: .public): \(error.localizedDescription.debugDescription, privacy: .public)")
}
}
}
}
extension SettingsStore {
public protocol SettingsKey<Value>: Equatable, Codable, Sendable {
associatedtype Value: Codable
static var defaultValue: Value { get }
}
}
extension SettingsStore {
struct SettingValue<Value: Codable>: Codable {
let value: Value
}
}
extension SettingsStore {
fileprivate struct KeychainError: Error {
let statusCode: OSStatus?
}
}
fileprivate func KeychainDictionary(_ dictionary: [CFString: Any]) -> CFDictionary {
dictionary as CFDictionary
}
extension SettingsStore {
enum Constants {
static let keyClass = kSecClassGenericPassword as String
static let keyTag = "com.maxgoedjen.settingsStore"
}
}