Implement biometric-protected GPG unlock

This commit is contained in:
2026-08-11 18:32:53 +02:00
parent 3295761bcf
commit 873db91204
15 changed files with 2043 additions and 59 deletions

View File

@@ -4,6 +4,9 @@ extension Notification.Name {
static let ironStorageLocalStoreDidChange = Notification.Name(
"de.rfc1437.ironstorage.local-store-did-change"
)
static let ironStorageAuthenticationDidChange = Notification.Name(
"de.rfc1437.ironstorage.authentication-did-change"
)
}
@main
@@ -33,15 +36,27 @@ private protocol MobileTabRoot: AnyObject {
@MainActor
private final class AppContext: NSObject, UITabBarControllerDelegate {
private let tabs = UITabBarController()
private let authentication = try? mobileAuthentication()
private var navigationControllers: [UINavigationController] = []
private var restoreTask: Task<Void, Never>?
private var authenticationMonitor: Task<Void, Never>?
deinit {
restoreTask?.cancel()
authenticationMonitor?.cancel()
}
func makeRootController() -> UIViewController {
let shell = mobileShellFixture(state: .loading)
navigationControllers = shell.pages.map { page in
let root: UIViewController = page.tab == .passwords
? PasswordDirectoryViewController(shellPage: page)
: ShellViewController(page: page)
let root: UIViewController = switch page.tab {
case .passwords:
PasswordDirectoryViewController(shellPage: page, authentication: authentication)
case .preferences:
PreferencesViewController(page: page, authentication: authentication)
default:
ShellViewController(page: page)
}
let navigation = UINavigationController(rootViewController: root)
navigation.navigationBar.prefersLargeTitles = true
navigation.tabBarItem = UITabBarItem(
@@ -54,6 +69,7 @@ private final class AppContext: NSObject, UITabBarControllerDelegate {
tabs.viewControllers = navigationControllers
tabs.delegate = self
restoreSelectedTab()
monitorAuthentication()
return tabs
}
@@ -90,6 +106,29 @@ private final class AppContext: NSObject, UITabBarControllerDelegate {
}
}
}
private func monitorAuthentication() {
authenticationMonitor?.cancel()
guard let authentication else { return }
authenticationMonitor = Task {
var wasUnlocked = (try? authentication.state().unlocked) ?? false
while !Task.isCancelled {
do {
try await Task.sleep(for: .seconds(1))
} catch {
return
}
let isUnlocked = (try? authentication.state().unlocked) ?? false
if isUnlocked != wasUnlocked {
wasUnlocked = isUnlocked
NotificationCenter.default.post(
name: .ironStorageAuthenticationDidChange,
object: authentication
)
}
}
}
}
}
@MainActor
@@ -614,17 +653,245 @@ private final class ShellViewController: UITableViewController, MobileTabRoot {
}
}
@MainActor
private final class PreferencesViewController: UITableViewController, MobileTabRoot {
fileprivate let shellTab = MobileTab.preferences
private var page: MobilePage
private let authentication: MobileAuthentication?
private var state: MobileAuthenticationState?
private var preferenceTask: Task<Void, Never>?
private var loadTask: Task<Void, Never>?
private var loadGeneration = 0
init(page: MobilePage, authentication: MobileAuthentication?) {
self.page = page
self.authentication = authentication
super.init(style: .insetGrouped)
title = page.title
navigationItem.largeTitleDisplayMode = .always
navigationItem.rightBarButtonItem = UIBarButtonItem(
title: "Update Token",
style: .plain,
target: self,
action: #selector(tokenUpdateRequested)
)
NotificationCenter.default.addObserver(
self,
selector: #selector(authenticationDidChange),
name: .ironStorageAuthenticationDidChange,
object: nil
)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) is not supported")
}
deinit {
preferenceTask?.cancel()
loadTask?.cancel()
NotificationCenter.default.removeObserver(self)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
reloadShell()
}
override func numberOfSections(in tableView: UITableView) -> Int {
page.state == .ready ? 2 : 0
}
override func tableView(
_ tableView: UITableView,
numberOfRowsInSection section: Int
) -> Int {
section == 0 ? 1 : 2
}
override func tableView(
_ tableView: UITableView,
titleForHeaderInSection section: Int
) -> String? {
section == 0 ? "Secure Unlock" : "Authentication Session"
}
override func tableView(
_ tableView: UITableView,
titleForFooterInSection section: Int
) -> String? {
if section == 0 {
return "When enabled, the GPG passphrase is device-only, requires a device passcode, and is invalidated when enrolled biometrics change."
}
return "Manual lock and inactivity expiry immediately revoke the shared Rust authentication lease."
}
override func tableView(
_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil)
var content = cell.defaultContentConfiguration()
if indexPath.section == 0 {
content.image = UIImage(systemName: "faceid")
content.text = "Biometric Unlock"
content.secondaryText = state?.biometricUnlockEnabled == true
? "Protected passphrase enrolled"
: "Manual passphrase required"
let toggle = UISwitch()
toggle.isOn = state?.biometricUnlockEnabled == true
toggle.isEnabled = authentication != nil
toggle.addTarget(self, action: #selector(biometricToggleChanged(_:)), for: .valueChanged)
toggle.accessibilityLabel = "Biometric Unlock"
cell.accessoryView = toggle
cell.selectionStyle = .none
} else if indexPath.row == 0 {
let unlocked = state?.unlocked == true
content.image = UIImage(systemName: unlocked ? "lock.open.fill" : "lock.fill")
content.text = unlocked ? "Unlocked" : "Locked"
content.secondaryText = unlocked
? "Locks in \(state?.remainingSeconds ?? 0) seconds without activity"
: "Protected content is masked"
cell.selectionStyle = .none
} else {
content.image = UIImage(systemName: "lock.fill")
content.text = "Lock Now"
content.textProperties.color = .systemRed
content.secondaryText = "Revoke all active authentication handles"
cell.accessoryType = .disclosureIndicator
cell.isUserInteractionEnabled = state?.unlocked == true
cell.contentView.alpha = state?.unlocked == true ? 1 : 0.45
}
content.secondaryTextProperties.numberOfLines = 0
cell.contentConfiguration = content
return cell
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
guard indexPath.section == 1, indexPath.row == 1, let authentication else { return }
do {
try authentication.manualLock()
refreshState()
NotificationCenter.default.post(
name: .ironStorageAuthenticationDidChange,
object: authentication
)
UIAccessibility.post(notification: .announcement, argument: "IronStorage locked")
} catch let error as MobileAuthenticationFfiError {
presentAuthenticationFailure(AuthenticationFailure(error))
} catch {
presentAuthenticationFailure(.unexpected)
}
}
override func viewDidLoad() {
super.viewDidLoad()
apply(page)
}
private func reloadShell() {
loadGeneration += 1
let generation = loadGeneration
loadTask?.cancel()
loadTask = Task { [weak self] in
let shell = await Task.detached(priority: .userInitiated) { mobileShell() }.value
guard
!Task.isCancelled,
let self,
generation == loadGeneration,
let page = shell.pages.first(where: { $0.tab == .preferences })
else { return }
apply(page)
}
}
private func apply(_ page: MobilePage) {
self.page = page
title = page.title
guard page.state == .ready else {
var configuration = UIContentUnavailableConfiguration.empty()
configuration.image = UIImage(systemName: "gearshape")
configuration.text = page.stateTitle
configuration.secondaryText = page.stateDetail
contentUnavailableConfiguration = configuration
tableView.reloadData()
return
}
contentUnavailableConfiguration = nil
refreshState()
}
@objc private func tokenUpdateRequested() {
navigationController?.pushViewController(TokenUpdateViewController(), animated: true)
}
@objc private func authenticationDidChange() {
refreshState()
}
@objc private func biometricToggleChanged(_ sender: UISwitch) {
guard let authentication else {
sender.setOn(false, animated: true)
presentAuthenticationFailure(.unavailable)
return
}
sender.isEnabled = false
let enabled = sender.isOn
preferenceTask?.cancel()
preferenceTask = Task { [weak self, weak sender] in
let result = await Task.detached(priority: .userInitiated) {
do {
return Result<MobileAuthenticationState, AuthenticationFailure>.success(
try authentication.setBiometricUnlock(enabled: enabled)
)
} catch let error as MobileAuthenticationFfiError {
return .failure(AuthenticationFailure(error))
} catch {
return .failure(.unexpected)
}
}.value
guard !Task.isCancelled, let self else { return }
sender?.isEnabled = true
switch result {
case let .success(state):
self.state = state
tableView.reloadData()
NotificationCenter.default.post(
name: .ironStorageAuthenticationDidChange,
object: authentication
)
case let .failure(failure):
sender?.setOn(!enabled, animated: true)
presentAuthenticationFailure(failure)
}
}
}
private func refreshState() {
state = try? authentication?.state()
tableView.reloadData()
}
}
@MainActor
private final class PasswordDirectoryViewController: UITableViewController, MobileTabRoot {
fileprivate let shellTab = MobileTab.passwords
private let path: String?
private let authentication: MobileAuthentication?
private var shellPage: MobilePage?
private var directoryPage: MobilePasswordPage?
private var loadTask: Task<Void, Never>?
private var loadGeneration = 0
init(shellPage: MobilePage, path: String? = nil) {
init(
shellPage: MobilePage,
authentication: MobileAuthentication?,
path: String? = nil
) {
self.shellPage = shellPage
self.authentication = authentication
self.path = path
super.init(style: .insetGrouped)
title = shellPage.title
@@ -639,8 +906,9 @@ private final class PasswordDirectoryViewController: UITableViewController, Mobi
)
}
private init(path: String, title: String) {
private init(path: String, title: String, authentication: MobileAuthentication?) {
self.path = path
self.authentication = authentication
shellPage = nil
super.init(style: .insetGrouped)
self.title = title
@@ -719,9 +987,13 @@ private final class PasswordDirectoryViewController: UITableViewController, Mobi
guard let row = directoryPage?.rows[indexPath.row] else { return }
let controller: UIViewController = switch row.kind {
case .directory:
PasswordDirectoryViewController(path: row.path, title: row.title)
PasswordDirectoryViewController(
path: row.path,
title: row.title,
authentication: authentication
)
case .entry:
LockedPasswordViewController(entry: row)
LockedPasswordViewController(entry: row, authentication: authentication)
}
navigationController?.pushViewController(controller, animated: true)
}
@@ -872,9 +1144,17 @@ private struct PasswordFailure: Error, Sendable {
@MainActor
private final class LockedPasswordViewController: UIViewController {
private let entry: MobilePasswordRow
private let authentication: MobileAuthentication?
private let imageView = UIImageView()
private let titleLabel = UILabel()
private let detailLabel = UILabel()
private let unlockButton = UIButton(type: .system)
private var unlockTask: Task<Void, Never>?
private var state: MobileAuthenticationState?
init(entry: MobilePasswordRow) {
init(entry: MobilePasswordRow, authentication: MobileAuthentication?) {
self.entry = entry
self.authentication = authentication
super.init(nibName: nil, bundle: nil)
title = entry.title
navigationItem.largeTitleDisplayMode = .never
@@ -888,12 +1168,230 @@ private final class LockedPasswordViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemGroupedBackground
var configuration = UIContentUnavailableConfiguration.empty()
configuration.image = UIImage(systemName: "lock.fill")
configuration.text = "Locked Password"
configuration.secondaryText = "Authenticate to reveal this password entry."
contentUnavailableConfiguration = configuration
imageView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(pointSize: 44)
imageView.tintColor = .secondaryLabel
titleLabel.font = .preferredFont(forTextStyle: .title2)
titleLabel.adjustsFontForContentSizeCategory = true
titleLabel.textAlignment = .center
detailLabel.font = .preferredFont(forTextStyle: .body)
detailLabel.adjustsFontForContentSizeCategory = true
detailLabel.textAlignment = .center
detailLabel.textColor = .secondaryLabel
detailLabel.numberOfLines = 0
unlockButton.configuration = .filled()
unlockButton.configuration?.cornerStyle = .capsule
unlockButton.addTarget(self, action: #selector(unlockRequested), for: .touchUpInside)
let stack = UIStackView(arrangedSubviews: [imageView, titleLabel, detailLabel, unlockButton])
stack.axis = .vertical
stack.alignment = .center
stack.spacing = 12
stack.setCustomSpacing(24, after: detailLabel)
stack.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(stack)
NSLayoutConstraint.activate([
stack.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor),
stack.leadingAnchor.constraint(greaterThanOrEqualTo: view.layoutMarginsGuide.leadingAnchor),
stack.trailingAnchor.constraint(lessThanOrEqualTo: view.layoutMarginsGuide.trailingAnchor),
detailLabel.widthAnchor.constraint(lessThanOrEqualToConstant: 360),
unlockButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 140),
])
view.accessibilityIdentifier = entry.id
NotificationCenter.default.addObserver(
self,
selector: #selector(authenticationDidChange),
name: .ironStorageAuthenticationDidChange,
object: nil
)
render()
}
deinit {
unlockTask?.cancel()
NotificationCenter.default.removeObserver(self)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
refreshState()
}
@objc private func authenticationDidChange() {
refreshState()
}
@objc private func unlockRequested() {
if state?.unlocked == true, let authentication {
do {
try authentication.touchUserActivity()
refreshState()
UIAccessibility.post(notification: .announcement, argument: "Unlock extended")
} catch let error as MobileAuthenticationFfiError {
presentAuthenticationFailure(AuthenticationFailure(error))
} catch {
presentAuthenticationFailure(.unexpected)
}
return
}
unlock(passphrase: nil)
}
@objc private func lockRequested() {
guard let authentication else { return }
do {
try authentication.manualLock()
state = try authentication.state()
NotificationCenter.default.post(
name: .ironStorageAuthenticationDidChange,
object: authentication
)
render()
} catch let error as MobileAuthenticationFfiError {
presentAuthenticationFailure(AuthenticationFailure(error))
} catch {
presentAuthenticationFailure(.unexpected)
}
}
private func refreshState() {
state = try? authentication?.state()
render()
}
private func unlock(passphrase: String?) {
guard let authentication else {
presentAuthenticationFailure(.unavailable)
return
}
unlockTask?.cancel()
unlockButton.isEnabled = false
unlockButton.configuration?.showsActivityIndicator = true
let path = entry.path
unlockTask = Task { [weak self] in
let result = await Task.detached(priority: .userInitiated) {
do {
return Result<MobileAuthenticationState, AuthenticationFailure>.success(
try authentication.unlockEntry(path: path, passphrase: passphrase)
)
} catch let error as MobileAuthenticationFfiError {
return .failure(AuthenticationFailure(error))
} catch {
return .failure(.unexpected)
}
}.value
guard !Task.isCancelled, let self else { return }
unlockButton.isEnabled = true
unlockButton.configuration?.showsActivityIndicator = false
switch result {
case let .success(state):
self.state = state
NotificationCenter.default.post(
name: .ironStorageAuthenticationDidChange,
object: authentication
)
render()
UIAccessibility.post(notification: .announcement, argument: "Password unlocked")
case let .failure(failure):
if passphrase == nil,
failure.kind == .passphraseRequired || failure.kind == .biometryUnavailable {
promptForPassphrase(message: failure.detail)
} else if failure.kind != .cancelled {
presentAuthenticationFailure(failure)
}
}
}
}
private func promptForPassphrase(message: String) {
let alert = UIAlertController(
title: "GPG Key Passphrase",
message: message,
preferredStyle: .alert
)
alert.addTextField { field in
field.isSecureTextEntry = true
field.textContentType = .password
field.placeholder = "Passphrase"
field.returnKeyType = .go
}
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
alert.addAction(UIAlertAction(title: "Unlock", style: .default) { [weak self, weak alert] _ in
guard let field = alert?.textFields?.first, let value = field.text, !value.isEmpty else {
return
}
field.text = nil
self?.unlock(passphrase: value)
})
present(alert, animated: true)
}
private func render() {
let unlocked = state?.unlocked == true
imageView.image = UIImage(systemName: unlocked ? "lock.open.fill" : "lock.fill")
titleLabel.text = unlocked ? "Key Unlocked" : "Locked Password"
detailLabel.text = if unlocked {
"The GPG key is available for protected operations for up to \(state?.remainingSeconds ?? 0) seconds."
} else {
"Authenticate to unlock this password entry. Browsing remains available while locked."
}
unlockButton.configuration?.title = unlocked ? "Extend Unlock" : "Unlock"
navigationItem.rightBarButtonItem = unlocked
? UIBarButtonItem(
image: UIImage(systemName: "lock.fill"),
style: .plain,
target: self,
action: #selector(lockRequested)
)
: nil
navigationItem.rightBarButtonItem?.accessibilityLabel = "Lock IronStorage"
unlockButton.accessibilityHint = unlocked
? "Extends access after authentication."
: "Requests biometric authentication or the GPG key passphrase."
}
}
private struct AuthenticationFailure: Error, Sendable {
let kind: MobileAuthenticationErrorKind
let title: String
let detail: String
init(_ error: MobileAuthenticationFfiError) {
switch error {
case let .Failed(kind, title, detail):
self.kind = kind
self.title = title
self.detail = detail
}
}
static let unexpected = AuthenticationFailure(
kind: .secureStorage,
title: "Unlock Failed",
detail: "IronStorage could not complete authentication."
)
static let unavailable = AuthenticationFailure(
kind: .configuration,
title: "Authentication Is Unavailable",
detail: "Finish password-store setup before unlocking entries."
)
private init(kind: MobileAuthenticationErrorKind, title: String, detail: String) {
self.kind = kind
self.title = title
self.detail = detail
}
}
@MainActor
private extension UIViewController {
func presentAuthenticationFailure(_ failure: AuthenticationFailure) {
let alert = UIAlertController(
title: failure.title,
message: failure.detail,
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "OK", style: .default))
present(alert, animated: true)
}
}