From ce9fb4a072e501a3b5d20926246ec709449c6934 Mon Sep 17 00:00:00 2001 From: NimBold Date: Wed, 10 Jun 2026 00:50:21 +0330 Subject: [PATCH] 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. --- Sources/Firelink/AppSettings.swift | 41 +++++++++++++++++++--- Sources/Firelink/ContentView.swift | 56 ++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 4 deletions(-) diff --git a/Sources/Firelink/AppSettings.swift b/Sources/Firelink/AppSettings.swift index b77ebb1..60a30e1 100644 --- a/Sources/Firelink/AppSettings.swift +++ b/Sources/Firelink/AppSettings.swift @@ -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, diff --git a/Sources/Firelink/ContentView.swift b/Sources/Firelink/ContentView.swift index d99b0c3..dff0aef 100644 --- a/Sources/Firelink/ContentView.swift +++ b/Sources/Firelink/ContentView.swift @@ -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) + } +}