feat(security): implement keychain permission priming

Adds a modal sheet to explain why keychain access is needed before prompting the user after an app update.
This commit is contained in:
NimBold
2026-06-10 00:50:21 +03:30
parent b7380bea35
commit ce9fb4a072
2 changed files with 93 additions and 4 deletions
+37 -4
View File
@@ -195,6 +195,8 @@ final class AppSettings: ObservableObject {
didSet { save() }
}
@Published var showKeychainPrimer = false
@Published var message = ""
private let defaults: UserDefaults
@@ -236,12 +238,30 @@ final class AppSettings: ObservableObject {
isKeychainAccessGranted = granted
}
let currentVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown"
let currentBuild = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "unknown"
let fullVersion = "\(currentVersion).\(currentBuild)"
let lastVersion = defaults.string(forKey: "Firelink.lastLaunchedVersion")
defaults.set(fullVersion, forKey: "Firelink.lastLaunchedVersion")
var needsPrimer = false
if granted {
if let token = KeychainCredentialStore.extensionToken() {
extensionPairingToken = token
if let lastVersion, lastVersion != fullVersion {
needsPrimer = true
}
}
if granted {
if needsPrimer {
showKeychainPrimer = true
extensionPairingToken = ""
} else {
extensionPairingToken = Self.generateSecureToken()
// The didSet of extensionPairingToken will handle setting it in the keychain since isKeychainAccessGranted is true.
if let token = KeychainCredentialStore.extensionToken() {
extensionPairingToken = token
} else {
extensionPairingToken = Self.generateSecureToken()
// The didSet of extensionPairingToken will handle setting it in the keychain since isKeychainAccessGranted is true.
}
}
} else {
extensionPairingToken = ""
@@ -367,6 +387,19 @@ final class AppSettings: ObservableObject {
isKeychainAccessGranted = false
}
func resolveKeychainPrimer(grantAccess: Bool) {
showKeychainPrimer = false
if grantAccess {
if let token = KeychainCredentialStore.extensionToken() {
extensionPairingToken = token
} else {
extensionPairingToken = Self.generateSecureToken()
}
} else {
isKeychainAccessGranted = false
}
}
private func save() {
let stored = StoredSettings(
appTheme: appTheme,
+56
View File
@@ -47,6 +47,10 @@ struct ContentView: View {
}
return true
}
.sheet(isPresented: $settings.showKeychainPrimer) {
KeychainPrimerView()
.environmentObject(settings)
}
}
@ViewBuilder
@@ -243,3 +247,55 @@ struct ContentView: View {
}
}
}
struct KeychainPrimerView: View {
@EnvironmentObject private var settings: AppSettings
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack(spacing: 24) {
Image(systemName: "lock.shield")
.resizable()
.scaledToFit()
.frame(width: 64, height: 64)
.foregroundStyle(settings.appTheme.theme.accent ?? Color.accentColor)
.padding(.top, 16)
VStack(spacing: 8) {
Text("Security Update")
.font(.title2)
.fontWeight(.semibold)
Text("Firelink has been updated. To keep your browser extension running smoothly and your site logins secure, please re-authorize access to your Mac's Keychain on the next prompt.")
.font(.body)
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
.padding(.horizontal)
}
VStack(spacing: 12) {
Button {
settings.resolveKeychainPrimer(grantAccess: true)
} label: {
Text("Grant Secure Access")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
Button {
settings.resolveKeychainPrimer(grantAccess: false)
} label: {
Text("Not Now")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderless)
.controlSize(.large)
}
.padding(.horizontal, 32)
.padding(.bottom, 16)
}
.padding(24)
.frame(width: 400)
}
}