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"
}
}

View File

@@ -25,6 +25,8 @@
50153E20250AFCB200525160 /* UpdateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50153E1F250AFCB200525160 /* UpdateView.swift */; };
50153E22250DECA300525160 /* SecretListItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50153E21250DECA300525160 /* SecretListItemView.swift */; };
501578132E6C0479004A37D0 /* XPCInputParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 501578122E6C0479004A37D0 /* XPCInputParser.swift */; };
5018D826304F6F1600F1FEE8 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5018D825304F6F1600F1FEE8 /* SettingsView.swift */; };
5018D828304F6F4200F1FEE8 /* SettingsKit in Frameworks */ = {isa = PBXBuildFile; productRef = 5018D827304F6F4200F1FEE8 /* SettingsKit */; };
5018F54F24064786002EB505 /* Notifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5018F54E24064786002EB505 /* Notifier.swift */; };
504788F22E681F3A00B4556F /* Instructions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504788F12E681F3A00B4556F /* Instructions.swift */; };
504788F42E681F6900B4556F /* ToolConfigurationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504788F32E681F6900B4556F /* ToolConfigurationView.swift */; };
@@ -236,6 +238,7 @@
50153E1F250AFCB200525160 /* UpdateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateView.swift; sourceTree = "<group>"; };
50153E21250DECA300525160 /* SecretListItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecretListItemView.swift; sourceTree = "<group>"; };
501578122E6C0479004A37D0 /* XPCInputParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XPCInputParser.swift; sourceTree = "<group>"; };
5018D825304F6F1600F1FEE8 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
5018F54E24064786002EB505 /* Notifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Notifier.swift; sourceTree = "<group>"; };
504788F12E681F3A00B4556F /* Instructions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Instructions.swift; sourceTree = "<group>"; };
504788F32E681F6900B4556F /* ToolConfigurationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolConfigurationView.swift; sourceTree = "<group>"; };
@@ -322,6 +325,7 @@
buildActionMask = 2147483647;
files = (
50E0145C2EDB9CDF00B121F1 /* Common in Frameworks */,
5018D828304F6F4200F1FEE8 /* SettingsKit in Frameworks */,
50E2058A2FAC2EB600402380 /* Formatters in Frameworks */,
5003EF3B278005E800DF2006 /* SecretKit in Frameworks */,
501421622781262300BBAA70 /* Brief in Frameworks */,
@@ -404,6 +408,7 @@
50153E21250DECA300525160 /* SecretListItemView.swift */,
50E204EC2FAA997F00402380 /* CertificateListItemView.swift */,
5079BA0E250F29BF00EA86F4 /* StoreListView.swift */,
5018D825304F6F1600F1FEE8 /* SettingsView.swift */,
);
path = Secrets;
sourceTree = "<group>";
@@ -653,6 +658,7 @@
505F5EF12FA9635700C45824 /* CertificateKit */,
50E205832FAB296A00402380 /* SharedXPCServices */,
50E205892FAC2EB600402380 /* Formatters */,
5018D827304F6F4200F1FEE8 /* SettingsKit */,
);
productName = Secretive;
productReference = 50617D7F23FCE48E0099B055 /* Secretive.app */;
@@ -772,7 +778,7 @@
attributes = {
BuildIndependentTargetsInParallel = YES;
LastSwiftUpdateCheck = 2700;
LastUpgradeCheck = 2640;
LastUpgradeCheck = 2700;
ORGANIZATIONNAME = "Max Goedjen";
TargetAttributes = {
5054028C3034B5E3000C3356 = {
@@ -916,6 +922,7 @@
508A58B3241ED2180069DC07 /* AgentStatusChecker.swift in Sources */,
50C385A52407A76D00AF2719 /* SecretDetailView.swift in Sources */,
5099A02423FD2AAA0062B6F2 /* CreateSecretView.swift in Sources */,
5018D826304F6F1600F1FEE8 /* SettingsView.swift in Sources */,
50AE97002E5C1A420018C710 /* IntegrationsView.swift in Sources */,
50153E20250AFCB200525160 /* UpdateView.swift in Sources */,
5066A6C82516FE6E004B5A36 /* CopyableView.swift in Sources */,
@@ -2092,6 +2099,10 @@
isa = XCSwiftPackageProductDependency;
productName = Brief;
};
5018D827304F6F4200F1FEE8 /* SettingsKit */ = {
isa = XCSwiftPackageProductDependency;
productName = SettingsKit;
};
505402A63034B7A4000C3356 /* XPCWrappers */ = {
isa = XCSwiftPackageProductDependency;
productName = XPCWrappers;

View File

@@ -4,10 +4,11 @@ import SecureEnclaveSecretKit
import SmartCardSecretKit
import Brief
import CertificateKit
import SettingsKit
@main
struct Secretive: App {
@Environment(\.agentLaunchController) var agentLaunchController
@Environment(\.justUpdatedChecker) var justUpdatedChecker
@@ -16,6 +17,7 @@ struct Secretive: App {
ContentView()
.environment(EnvironmentValues._secretStoreList)
.environment(EnvironmentValues._certificateStore)
.environment(EnvironmentValues._settingsStore)
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
Task {
@AppStorage("defaultsHasRunSetup") var hasRunSetup = false
@@ -42,6 +44,9 @@ struct Secretive: App {
}
.windowStyle(.hiddenTitleBar)
.windowResizability(.contentSize)
Settings {
SettingsView()
}
}
}
@@ -101,8 +106,9 @@ extension EnvironmentValues {
return list
}()
@MainActor fileprivate static let _certificateStore: CertificateStore = CertificateStore()
@MainActor fileprivate static let _certificateStore = CertificateStore()
@MainActor fileprivate static let _settingsStore = SettingsStore()
private static let _agentLaunchController = AgentLaunchController()
@Entry var agentLaunchController: any AgentLaunchControllerProtocol = _agentLaunchController
@@ -122,6 +128,10 @@ extension EnvironmentValues {
@MainActor var certificateStore: CertificateStore {
EnvironmentValues._certificateStore
}
@MainActor var settingsStore: SettingsStore {
EnvironmentValues._settingsStore
}
}
extension FocusedValues {

View File

@@ -0,0 +1,14 @@
import SwiftUI
import SettingsKit
struct SettingsView: View {
@Environment(\.settingsStore) var settingsStore
var body: some View {
Form {
}
.padding()
.frame(minWidth: 480, minHeight: 320)
}
}