fix(keychain): unify mobile hardware key editors

This commit is contained in:
Kaede Akino committed 2026-09-29 15:50:43 +08:00
1 parent f57c60a797
commit 707ccccc81
7 files changed
+217 -42

No files matched your search

@@ -74,6 +74,46 @@ struct SecurityKeyEditorTests {
#expect(!persisted)
}
@Test func dirtyStateTracksEachEditableFieldAndRevertingChanges() {
let existing = SSHKeyRef(label: "Key", securityKey: credential)
let model = SecurityKeyEditorModel(
existing: existing, exportReference: { _ in "reference" }, validate: { _ in }, persist: { _ in })
model.load()
#expect(!model.isDirty)
model.label = "Renamed"
#expect(model.isDirty)
model.label = existing.label
#expect(!model.isDirty)
model.colorTag = .purple
#expect(model.isDirty)
model.colorTag = existing.colorTag
#expect(!model.isDirty)
model.verification = .required
#expect(model.isDirty)
model.verification = credential.userVerification
#expect(!model.isDirty)
}
@Test func nativeEditorReceivesSaveFailuresAndCanRetryTheSameDraft() async throws {
var shouldFail = true
var persisted: SSHKeyRef?
let model = SecurityKeyEditorModel(
existing: SSHKeyRef(label: "Key", securityKey: credential),
exportReference: { _ in "reference" }, validate: { _ in },
persist: {
if shouldFail { throw EditFailure() }
persisted = $0
})
model.load()
model.label = "Renamed"
await #expect(throws: EditFailure.self) { try await model.saveChanges() }
#expect(persisted == nil && model.isDirty && model.canSave)
#expect(model.label == "Renamed")
shouldFail = false
try await model.saveChanges()
#expect(persisted?.label == "Renamed")
}
private struct EditFailure: LocalizedError {
var errorDescription: String? { "Fixture edit failure" }
}
@@ -14,6 +14,10 @@ public final class SecurityKeyEditorModel {
public private(set) var isSaving = false
public var canSave: Bool { originalCredential != nil && !isSaving }
public let existing: SSHKeyRef
public var isDirty: Bool {
label != existing.label || colorTag != existing.colorTag
|| verification != (originalCredential?.userVerification ?? .preferred)
}
private var originalCredential: SecurityKeyCredential?
private let exportReference: (SecurityKeyCredential) throws -> String
@@ -43,21 +47,26 @@ public final class SecurityKeyEditorModel {
} catch { self.error = error.localizedDescription }
}
public func save() async -> Bool {
guard !isSaving else { return false }
/// Throws so native editor containers can retain the draft and present save failures.
public func saveChanges() async throws {
guard !isSaving else { throw CancellationError() }
isSaving = true
error = nil
defer { isSaving = false }
guard var credential = originalCredential else { throw TermindSSHError.missingCredential }
credential.userVerification = verification
try validate(credential)
try Task.checkCancellation()
var key = existing
key.label = label.trimmingCharacters(in: .whitespacesAndNewlines)
key.colorTag = colorTag
key.securityKey = credential
try persist(key)
}
public func save() async -> Bool {
error = nil
do {
guard var credential = originalCredential else { throw TermindSSHError.missingCredential }
credential.userVerification = verification
try validate(credential)
try Task.checkCancellation()
var key = existing
key.label = label.trimmingCharacters(in: .whitespacesAndNewlines)
key.colorTag = colorTag
key.securityKey = credential
try persist(key)
try await saveChanges()
return true
} catch is CancellationError {
} catch {
@@ -23,25 +23,7 @@ public struct SecurityKeyEditorForm: View {
public var body: some View {
VStack(spacing: 0) {
Form {
Section {
Label(AppStrings.SecurityKey.title.swiftUIKey, systemImage: "key.radiowaves.forward.fill").font(
.headline)
VaultNameField(text: $model.label, prompt: Text(AppStrings.SecurityKey.title))
VaultColorField(selection: $model.colorTag)
}
Section(AppStrings.SecurityKey.reference.swiftUIKey) {
LabeledContent(AppStrings.Keychain.storeIn.swiftUIKey) {
Label(AppStrings.SecurityKey.title.swiftUIKey, systemImage: "key.radiowaves.forward.fill")
}
WebAuthnVerificationPicker(selection: $model.verification)
if !model.reference.isEmpty {
Text(model.reference).font(.caption.monospaced()).textSelection(.enabled)
}
}
Section(AppStrings.Keychain.publicKey.swiftUIKey) {
Text(model.existing.publicKey).font(.caption.monospaced()).textSelection(.enabled)
}
if let error = model.error { Text(error).foregroundStyle(.red) }
SecurityKeyEditorSections(model: model, showsTitle: true)
}
.formStyle(.grouped)
HStack {
@@ -0,0 +1,39 @@
import SwiftUI
import TermindApplication
import TermindDomain
/// Shared fields; each platform owns the surrounding editor and actions.
public struct SecurityKeyEditorSections: View {
@Bindable private var model: SecurityKeyEditorModel
private let showsTitle: Bool
public init(model: SecurityKeyEditorModel, showsTitle: Bool = false) {
self.model = model
self.showsTitle = showsTitle
}
public var body: some View {
Section {
if showsTitle {
Label(AppStrings.SecurityKey.title.swiftUIKey, systemImage: "key.radiowaves.forward.fill")
.font(.headline)
}
VaultNameField(text: $model.label, prompt: Text(AppStrings.SecurityKey.title))
VaultColorField(selection: $model.colorTag)
}
Section(AppStrings.SecurityKey.reference.swiftUIKey) {
LabeledContent(AppStrings.Keychain.storeIn.swiftUIKey) {
Label(AppStrings.SecurityKey.title.swiftUIKey, systemImage: "key.radiowaves.forward.fill")
.labelStyle(.titleAndIcon)
}
WebAuthnVerificationPicker(selection: $model.verification)
if !model.reference.isEmpty {
Text(model.reference).font(.caption.monospaced()).textSelection(.enabled)
}
}
Section(AppStrings.Keychain.publicKey.swiftUIKey) {
Text(model.existing.publicKey).font(.caption.monospaced()).textSelection(.enabled)
}
if let error = model.error { Text(error).foregroundStyle(.red) }
}
}
@@ -152,6 +152,7 @@ struct MobileKeyEditor: View {
Section(AppStrings.Keychain.secureEnclave.swiftUIKey) {
LabeledContent(AppStrings.Keychain.storeIn.swiftUIKey) {
Label(AppStrings.Keychain.secureEnclave.swiftUIKey, systemImage: "checkmark.shield.fill")
.labelStyle(.titleAndIcon)
}
}
}
+14 -11
View File
@@ -5,18 +5,21 @@ import TermindPresentation
import TermindRemoteInfrastructure
struct SecurityKeyEditor: View {
let credentials: CredentialLibraryModel
let existing: SSHKeyRef
var close: (() -> Void)? = nil
@Environment(\.dismiss) private var dismiss
@State private var model: SecurityKeyEditorModel
init(credentials: CredentialLibraryModel, existing: SSHKeyRef) {
let model = SecurityKeyEditorModel(
existing: existing, exportReference: WebAuthnSSH.exportReference, validate: WebAuthnSSH.validate,
persist: { try credentials.upsert($0) })
model.load()
_model = State(initialValue: model)
}
var body: some View {
SecurityKeyEditorForm(
existing: existing, exportReference: WebAuthnSSH.exportReference, validate: WebAuthnSSH.validate,
persist: { try credentials.upsert($0) }, close: finish)
}
private func finish() {
if let close { close() } else { dismiss() }
MobileEditorSheet(
title: String(localized: AppStrings.Keychain.editKey),
isDirty: model.isDirty, canSave: model.canSave,
save: { try await model.saveChanges() },
content: { SecurityKeyEditorSections(model: model) })
}
}
@@ -0,0 +1,101 @@
import CryptoKit
import SwiftUI
import TermindApplication
import TermindDomain
import TermindPresentation
import TermindRemoteInfrastructure
import Testing
import UIKit
@testable import TermindIOS
@Suite(
.enabled(
if: ProcessInfo.processInfo.environment["TERMIND_MOBILE_UI_TESTS"] == "1",
"Set TERMIND_MOBILE_UI_TESTS=1 and disable parallel testing for key-window integration tests"),
.serialized)
@MainActor
struct MobileHardwareKeyEditorRenderingTests {
@Test(arguments: ["en", "zh-Hans"], [false, true])
func hardwareKeyEditorsUseNativeActionsAndCompactStorageRows(language: String, enclave: Bool) async throws {
let key: SSHKeyRef
if enclave {
key = SSHKeyRef(
label: "Device Key", algorithm: .ecdsa256,
publicKey: "ecdsa-sha2-nistp256 " + String(repeating: "AAAA", count: 40),
fingerprint: "SHA256:fixture", storage: .secureEnclave)
} else {
key = try WebAuthnSSH.keyReference(
SecurityKeyCredential(
credentialID: Data(repeating: 1, count: 64),
publicKeyX963: P256.Signing.PrivateKey().publicKey.x963Representation), label: "Hardware Key")
}
let credentials = CredentialLibraryModel(library: HostLibraryModel(keys: [key]))
let scene = try #require(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first)
let previous = scene.keyWindow
let window = UIWindow(windowScene: scene)
let controller = UIHostingController(
rootView: HardwareKeyEditorHost(credentials: credentials, key: key, enclave: enclave)
.environment(\.locale, Locale(identifier: language)))
window.rootViewController = controller
window.windowLevel = .alert + 1
window.makeKeyAndVisible()
defer {
window.isHidden = true
window.rootViewController = nil
previous?.makeKey()
}
try await Task.sleep(for: .milliseconds(800))
let sheet = try #require(controller.presentedViewController)
sheet.view.layoutIfNeeded()
let navigation = try #require(descendant(UINavigationController.self, in: sheet))
let item = try #require(navigation.topViewController?.navigationItem)
let buttons =
item.leadingItemGroups.flatMap(\.barButtonItems)
+ item.trailingItemGroups.flatMap(\.barButtonItems)
+ (item.leftBarButtonItems ?? []) + (item.rightBarButtonItems ?? [])
let names = Set(buttons.compactMap { $0.accessibilityLabel ?? $0.title })
#expect(!names.isDisjoint(with: ["Save", "保存"]))
#expect(!names.isDisjoint(with: ["Cancel", "取消"]))
let collection = try #require(descendant(UICollectionView.self, in: sheet.view))
let storage = try #require(collection.cellForItem(at: IndexPath(item: 0, section: 1)))
#expect(storage.bounds.height < 100, "Storage row height: \(storage.bounds.height)")
let image = UIGraphicsImageRenderer(bounds: window.bounds).image { _ in
window.drawHierarchy(in: window.bounds, afterScreenUpdates: true)
}
let data = try #require(image.pngData())
let directory = FileManager.default.temporaryDirectory.appending(path: "hardware-key-editors")
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let device = UIDevice.current.userInterfaceIdiom == .pad ? "ipad" : "iphone"
let output = directory.appending(path: "\(device)-\(enclave ? "enclave" : "security-key")-\(language).png")
try data.write(to: output)
print("Hardware key editor snapshot: \(output.path)")
}
private func descendant<T: UIViewController>(_ type: T.Type, in controller: UIViewController) -> T? {
if let value = controller as? T { return value }
return controller.children.lazy.compactMap { descendant(type, in: $0) }.first
}
private func descendant<T: UIView>(_ type: T.Type, in view: UIView) -> T? {
if let value = view as? T { return value }
return view.subviews.lazy.compactMap { descendant(type, in: $0) }.first
}
}
private struct HardwareKeyEditorHost: View {
let credentials: CredentialLibraryModel
let key: SSHKeyRef
let enclave: Bool
@State private var presented = true
var body: some View {
Color.clear.sheet(isPresented: $presented) {
if enclave {
MobileKeyEditor(model: MobileKeyEditorModel(mode: .edit, credentials: credentials, key: key))
} else {
SecurityKeyEditor(credentials: credentials, existing: key)
}
}
}
}