// SPDX-FileCopyrightText: 2026 Kaede Akino // SPDX-License-Identifier: GPL-3.0-only import SwiftUI import TermindApplication import TermindDomain import TermindPresentation import TermindRemoteInfrastructure import UniformTypeIdentifiers import struct TermindDomain.Host @Observable final class KeychainPageState { var query = "" var errorMessage: String? var deleting: [KeychainPageView.Item] = [] func requestDeletion(of items: [KeychainPageView.Item]) { guard !items.isEmpty, DeletionContentCategory.protectedData.requiresConfirmation(for: .item) else { return } deleting = items } } /// Everything Termind knows about how you prove who you are — in two parts. /// /// **Keys** are key pairs: a label, an algorithm, a fingerprint, and a Keychain /// reference. **Identities** are what a connection actually uses: a username plus /// either one of those keys or a password. The split exists because the two are /// not one-to-one — one key signs for `deploy` on the web tier and `postgres` on /// the database box, and copying the key twice to say that would be a lie about /// how many keys you have. /// /// Private material is loaded only by the explicit Import/Edit work surfaces; /// cards and selection details continue to operate on public metadata. struct KeychainPageView: ManagedCardPage { nonisolated enum SectionID: Hashable { case keys case identities } nonisolated enum Item: Identifiable, Hashable, Sendable { case key(SSHKeyRef) case identity(Identity) var id: VaultSelectionID { switch self { case .key(let key): .key(key.id) case .identity(let identity): .identity(identity.id) } } var key: SSHKeyRef? { if case .key(let key) = self { return key } return nil } var identity: Identity? { if case .identity(let identity) = self { return identity } return nil } } @Environment(AppModel.self) private var app @Environment(WindowModel.self) private var window @Environment(VaultWorkEditingSession.self) private var editingSession let state: KeychainPageState init(state: KeychainPageState = KeychainPageState()) { self.state = state } private var keys: [SSHKeyRef] { app.credentials.searchKeys(state.query) } private var identities: [Identity] { app.credentials.searchIdentities(state.query) } private var selection: Set { Set(window.vaultSelection(for: .keychain)) } private var isEmpty: Bool { app.credentials.keys.isEmpty && app.credentials.identities.isEmpty } // MARK: - Page var managedSections: [ManagedCardSection] { [ ManagedCardSection( id: .keys, title: String(localized: AppStrings.Keychain.keys), items: keys.map(Item.key), showsWhenEmpty: state.query.isEmpty ), ManagedCardSection( id: .identities, title: String(localized: AppStrings.Keychain.identities), items: identities.map(Item.identity), showsWhenEmpty: state.query.isEmpty ), ] } var managedSelection: ManagedCardSelection { ManagedCardSelection(selected: { selection }, update: setSelection) } var managedCapabilities: ManagedCardCapabilities { ManagedCardCapabilities( selection: .multiple, marqueeSelection: true, reordering: ManagedCardReordering( isEnabled: state.query.isEmpty, dropPolicy: .sameSection ) { ids, destination, next in switch destination { case .keys: let keyIDs = ids.compactMap(\.keyID) app.credentials.move(keyIDs: keyIDs, before: next?.keyID) case .identities: let identityIDs = ids.compactMap(\.identityID) app.credentials.move(identityIDs: identityIDs, before: next?.identityID) } } ) } var isManagedSourceEmpty: Bool { isEmpty } func managedHeader( for section: ManagedCardSection, isDropTarget: Bool ) -> some View { CardSectionHeader( title: section.title, count: section.items.count, isDropTarget: isDropTarget ) } @ViewBuilder func managedCard( for item: Item, context: ManagedCardItemContext ) -> some View { switch item { case .key(let key): KeyCard( key: key, isSelected: context.isSelected, select: { select(item, action: context.select) }, extendSelection: context.extendSelection, actionKeys: context.actionItems.compactMap(\.key), actionIdentities: context.actionItems.compactMap(\.identity), edit: { edit(item, action: context.select) }, export: { requestExport(context.actionItems.compactMap(\.key)) }, delete: { requestDelete(context.actionItems) } ) case .identity(let identity): IdentityCard( identity: identity, isSelected: context.isSelected, select: { select(item, action: context.select) }, extendSelection: context.extendSelection, actionKeys: context.actionItems.compactMap(\.key), actionIdentities: context.actionItems.compactMap(\.identity), edit: { edit(item, action: context.select) }, delete: { requestDelete(context.actionItems) } ) } } @ViewBuilder func managedGhost(for item: Item) -> some View { switch item { case .key(let key): KeyCard( key: key, isSelected: false, select: {}, extendSelection: {}, actionKeys: [], actionIdentities: [], edit: {}, export: {}, delete: {} ) .allowsHitTesting(false) case .identity(let identity): IdentityCard( identity: identity, isSelected: false, select: {}, extendSelection: {}, actionKeys: [], actionIdentities: [], edit: {}, delete: {} ) .allowsHitTesting(false) } } /// Both sections offer every verb: a key and an identity are made in the /// same visit often enough that hiding one behind the other section would /// just be a second right-click. func managedBlankAreaMenu(for section: SectionID?) -> [NativeMenuEntry] { [ NativeMenuEntry( title: AppStrings.Keychain.importKey, symbol: MenuSymbol.importItem ) { window.openVaultWork(.importKey, on: .keychain) }, NativeMenuEntry( title: AppStrings.Keychain.generateKey, symbol: MenuSymbol.generateKey ) { window.openVaultWork(.generateKey, on: .keychain) }, .separator, NativeMenuEntry( title: AppStrings.Keychain.newIdentity, symbol: MenuSymbol.newIdentity ) { window.openVaultWork(.identityEditor(nil), on: .keychain) }, .separator, NativeMenuEntry(title: AppStrings.Vault.importSshFolder, symbol: MenuSymbol.importItem) { window.openVaultWork(.sshImport(knownHostsOnly: false), on: .keychain) }, ] } func managedBlankAreaPrimaryAction() { guard let route = window.vaultWorkRoute, isEditableRoute(route) else { return } editingSession.requestTransition(route: route, action: window.closeVaultWork) } // MARK: - Selection private func setSelection(_ selected: Set) { let ordered: [VaultSelectionID] = keys.compactMap { selected.contains(.key($0.id)) ? .key($0.id) : nil } + identities.compactMap { selected.contains(.identity($0.id)) ? .identity($0.id) : nil } window.setVaultSelection(ordered, for: .keychain) } private func select(_ item: Item, action: @escaping () -> Void) { guard let current = window.vaultWorkRoute, isEditableRoute(current) else { action() return } let target = editorRoute(for: item) guard current != target else { action() return } editingSession.requestTransition(route: current) { action() window.openVaultWork(target, on: .keychain) } } private func edit(_ item: Item, action: @escaping () -> Void) { let target = editorRoute(for: item) guard let current = window.vaultWorkRoute, isEditableRoute(current), current != target else { action() window.openVaultWork(target, on: .keychain) return } editingSession.requestTransition(route: current) { action() window.openVaultWork(target, on: .keychain) } } private func editorRoute(for item: Item) -> VaultWorkRoute { switch item { case .key(let key): .editKey(key.id) case .identity(let identity): .identityEditor(identity.id) } } private func isEditableRoute(_ route: VaultWorkRoute) -> Bool { switch route { case .editKey, .identityEditor, .importKey, .generateKey: true default: false } } private func requestExport(_ keys: [SSHKeyRef]) { guard keys.count == 1, let key = keys.first else { return } window.openVaultWork(.exportKey(key.id), on: .keychain) } private func requestDelete(_ items: [Item]) { state.requestDeletion(of: items) } /// Identities go first so deleting a mixed selection never leaves a host /// pointing at an identity whose key was removed moments earlier. Keychain /// failures leave that key selected and report the first error. private func delete(_ items: [Item]) { let keys = items.compactMap(\.key) let identities = items.compactMap(\.identity) for identity in identities { if !app.credentials.delete(identityID: identity.id) { state.errorMessage = app.credentials.storageError ?? String(localized: AppStrings.Keychain.theIdentityCouldNotBeDeleted) } } for key in keys { do { try app.credentials.delete(keyID: key.id) } catch { state.errorMessage = error.localizedDescription } } state.deleting = [] window.pruneVaultSelections(in: app.credentials) } func managedEmptyState() -> some View { ContentUnavailableView { Label(AppStrings.Keychain.keychainIsEmpty.swiftUIKey, systemImage: "key.fill") } description: { Text( AppStrings.Keychain.emptyDescription ) } actions: { // The only door out of the empty state that opens yet: an identity // can be a username and a password, which needs no key material. Button(AppStrings.Keychain.newIdentity.swiftUIKey) { window.openVaultWork(.identityEditor(nil), on: .keychain) } Button(AppStrings.Keychain.importKey.swiftUIKey) { window.openVaultWork(.importKey, on: .keychain) } Button(AppStrings.Keychain.generateKey.swiftUIKey) { window.openVaultWork(.generateKey, on: .keychain) } } .frame(maxWidth: .infinity, maxHeight: .infinity) } func managedNoResultsState() -> some View { ContentUnavailableView.search(text: state.query) } var managedPresentation: KeychainPagePresentation { KeychainPagePresentation( state: state, actions: KeychainPagePresentation.Actions( delete: delete, pruneSelection: { window.pruneVaultSelections(in: app.credentials) } ) ) } } struct KeychainPagePresentation: ManagedCardPresentationModifier { struct Actions { let delete: ([KeychainPageView.Item]) -> Void let pruneSelection: () -> Void } let state: KeychainPageState let actions: Actions func body(content: Content) -> some View { @Bindable var state = state content .onAppear(perform: actions.pruneSelection) .confirmationDialog( confirmationTitle, isPresented: Binding( get: { !state.deleting.isEmpty }, set: { if !$0 { state.deleting = [] } } ), titleVisibility: .visible ) { Button(AppStrings.Common.delete.swiftUIKey, role: .destructive) { actions.delete(state.deleting) } Button(AppStrings.Common.cancel.swiftUIKey, role: .cancel) { state.deleting = [] } } message: { confirmationMessage } // A refused Keychain delete used to be a line of orange text in the // page toolbar. With the toolbar gone it needs somewhere it cannot // be missed: leaving a key the user asked to remove sitting there // with no explanation is the one outcome worth interrupting for. .alert( AppStrings.Keychain.keychainOperationFailed, isPresented: Binding( get: { state.errorMessage != nil }, set: { if !$0 { state.errorMessage = nil } } ) ) { Button(AppStrings.Common.ok.swiftUIKey) { state.errorMessage = nil } } message: { Text(state.errorMessage ?? "") } } private var confirmationTitle: Text { let keys = state.deleting.compactMap(\.key) let identities = state.deleting.compactMap(\.identity) if keys.count == 1, identities.isEmpty { return Text(AppStrings.Keychain.deleteKeyConfirmation) } if keys.count > 1, identities.isEmpty { return Text(AppStrings.Keychain.deleteKeysConfirmation(keys.count)) } if identities.count == 1, keys.isEmpty { return Text(AppStrings.Keychain.deleteIdentityConfirmation) } if identities.count > 1, keys.isEmpty { return Text(AppStrings.Keychain.deleteIdentitiesConfirmation(identities.count)) } return Text(AppStrings.Keychain.deleteItemsConfirmation(keys.count + identities.count)) } private var confirmationMessage: Text { let keys = state.deleting.compactMap(\.key) let identities = state.deleting.compactMap(\.identity) if keys.count == 1, identities.isEmpty { return Text(AppStrings.Keychain.deleteKeyWarning) } if keys.count > 1, identities.isEmpty { return Text(AppStrings.Keychain.deleteKeysWarning) } if identities.count == 1, keys.isEmpty { return Text(AppStrings.Keychain.deleteIdentityWarning) } if identities.count > 1, keys.isEmpty { return Text(AppStrings.Keychain.deleteIdentitiesWarning) } return Text(AppStrings.Keychain.deleteItemsWarning) } } struct KeychainImportDetail: View { @Environment(AppModel.self) private var app @Environment(WindowModel.self) private var window var body: some View { SoftwareKeyEditor( title: AppStrings.Keychain.importKey, symbol: "square.and.arrow.down", close: window.closeVaultWork, defaultStorage: app.newVaultKeyStorage, saveSecurityKey: { name, color, credential in var key = try WebAuthnSSH.keyReference(credential, label: name) key.colorTag = color try app.credentials.upsert(key) window.selectVaultItem(.key(key.id), on: .keychain) }, save: save ) } private func save( name: String, colorTag: TagColor, storage: KeyStorage, privateKey: String, passphrase: String?, savePassphrase: Bool, certificate: String, material: SSHKeyMaterial ) async throws -> SSHKeyRef { let key = SSHKeyRef( label: name, algorithm: material.algorithm, publicKey: material.publicKey, certificate: certificate, fingerprint: material.fingerprint, credentialRef: "termind.key.\(UUID().uuidString)", storage: storage, keySize: material.keySize, hasPassphrase: try SSHKeyMaterial.privateKeyMetadata(privateKey).isEncrypted, createdAt: Date(), colorTag: colorTag ) try app.credentials.upsert( key, credential: .software( SSHPrivateKeySecret(privateKey: privateKey, passphrase: savePassphrase ? passphrase : nil) ) ) window.selectVaultItem(.key(key.id), on: .keychain) return key } } private struct SoftwareKeyEditor: View { private enum FileChoice { case privateKey case certificate } let saveSecurityKey: ((String, TagColor, SecurityKeyCredential) throws -> Void)? @State private var usesWebAuthn = false @State private var verification: SecurityKeyCredential.UserVerification = .preferred @State private var reference = "" @State private var referenceDropTask: Task? let existing: SSHKeyRef? let initialStorage: KeyStorage let title: LocalizedStringResource let symbol: String let close: () -> Void let loadPrivateKey: (() throws -> SSHPrivateKeySecret)? let save: ( _ name: String, _ colorTag: TagColor, _ storage: KeyStorage, _ privateKey: String, _ passphrase: String?, _ savePassphrase: Bool, _ certificate: String, _ material: SSHKeyMaterial ) async throws -> SSHKeyRef let saveMetadata: ((_ name: String, _ colorTag: TagColor, _ certificate: String, _ publicKey: String) throws -> SSHKeyRef)? let delete: (() throws -> Void)? @State private var name: String @State private var colorTag: TagColor @State private var storage: KeyStorage @State private var privateKey = "" @State private var originalPrivateKey = "" @State private var replacementDraft = SSHKeyReplacementDraft() @State private var publicKey: String @State private var passphrase = "" @State private var originalPassphrase = "" @State private var savePassphrase = true @State private var originalSavePassphrase = true @State private var certificate: String @State private var material: SSHKeyMaterial? @State private var privateKeyRequiresPassphrase = false @State private var certificateWarning: String? @State private var isLoading: Bool @State private var canEditPrivateKey = true @State private var isChoosingFile = false @State private var fileChoice: FileChoice = .privateKey @State private var isWorking = false @State private var errorMessage: String? @State private var nativeDropKind: NativeDropKind? @State private var confirmsDeletion = false @State private var editingToken: UUID? @Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(VaultWorkEditingSession.self) private var editingSession init( existing: SSHKeyRef? = nil, title: LocalizedStringResource, symbol: String, close: @escaping () -> Void, defaultStorage: KeyStorage = .iCloud, saveSecurityKey: ((String, TagColor, SecurityKeyCredential) throws -> Void)? = nil, loadPrivateKey: (() throws -> SSHPrivateKeySecret)? = nil, saveMetadata: ( (_ name: String, _ colorTag: TagColor, _ certificate: String, _ publicKey: String) throws -> SSHKeyRef )? = nil, delete: (() throws -> Void)? = nil, save: @escaping ( String, TagColor, KeyStorage, String, String?, Bool, String, SSHKeyMaterial ) async throws -> SSHKeyRef ) { self.saveSecurityKey = saveSecurityKey self.existing = existing initialStorage = existing?.storage ?? defaultStorage self.title = title self.symbol = symbol self.close = close self.loadPrivateKey = loadPrivateKey self.saveMetadata = saveMetadata self.delete = delete self.save = save _name = State(initialValue: existing?.label ?? "") _colorTag = State(initialValue: existing?.colorTag ?? .none) _storage = State(initialValue: existing?.storage ?? defaultStorage) _certificate = State(initialValue: existing?.certificate ?? "") _publicKey = State(initialValue: existing?.publicKey ?? "") _isLoading = State(initialValue: loadPrivateKey != nil) } var body: some View { VaultDetailScaffold( title: title, symbol: symbol, close: cancelEditing, closeLabel: existing == nil ? AppStrings.Common.close : AppStrings.Common.cancel ) { if isLoading { ProgressView(AppStrings.Keychain.loadingPrivateKeyProgress.swiftUIKey) .frame(maxWidth: .infinity, maxHeight: .infinity) } else { Form { if let errorMessage { Section { Label(errorMessage, systemImage: "exclamationmark.triangle.fill") .foregroundStyle(.orange) } } Section { VaultNameField(text: $name, prompt: Text(verbatim: defaultName)) VaultColorField(selection: $colorTag) } Section { KeyCreationStoragePicker( storage: $storage, usesWebAuthn: $usesWebAuthn, allowsWebAuthn: existing == nil && saveSecurityKey != nil ) .disabled(!canEditPrivateKey || existing?.storage == .iCloud) if usesWebAuthn { WebAuthnVerificationPicker(selection: $verification) keyTextEditor( text: $reference, minHeight: 150, placeholder: AppStrings.SecurityKey.importHelp ) .accessibilityLabel(AppStrings.SecurityKey.reference.swiftUIKey) .onDrop(of: [.plainText], isTargeted: nil, perform: receiveReferenceText) } else { keyTextEditor( text: $privateKey, minHeight: 150, placeholder: AppStrings.Keychain.pasteAPrivateKeyOrDragOneIn ) .accessibilityLabel(AppStrings.Keychain.privateKey.swiftUIKey) .disabled(!canEditPrivateKey) if let existing, let material, material.publicKey != existing.publicKey { Label( AppStrings.Keychain.replacePrivateKeyWarning.swiftUIKey, systemImage: "arrow.triangle.2.circlepath" ) .font(.caption) .foregroundStyle(.orange) } if privateKeyRequiresPassphrase { RevealableSecretField( AppStrings.Keychain.passphrase, text: $passphrase, prompt: Text(AppStrings.Common.optional) ) .disabled(!canEditPrivateKey) Toggle(AppStrings.Keychain.savePassphrase.swiftUIKey, isOn: $savePassphrase) .disabled(!canEditPrivateKey) } } } header: { fileSectionHeader( usesWebAuthn ? AppStrings.SecurityKey.reference : AppStrings.Keychain.privateKey, canChoose: canEditPrivateKey ) { chooseFile(for: .privateKey) } } if !usesWebAuthn { Section(AppStrings.Keychain.publicKey.swiftUIKey) { keyTextEditor( text: $publicKey, minHeight: 100, placeholder: AppStrings.KeyExport.optionalPublicKey ) .accessibilityLabel(AppStrings.Keychain.publicKey.swiftUIKey) } Section { keyTextEditor( text: $certificate, minHeight: 110, placeholder: AppStrings.Keychain.certificateOptional ) .accessibilityLabel(AppStrings.Keychain.certificate.swiftUIKey) if let warning = certificateWarning { Label(warning, systemImage: "exclamationmark.triangle.fill") .font(.caption) .foregroundStyle(.orange) } } header: { fileSectionHeader(AppStrings.Keychain.certificate) { chooseFile(for: .certificate) } } } } .formStyle(.grouped) } } footer: { HStack(spacing: Metrics.Space.m) { if existing != nil, delete != nil { Button(AppStrings.Common.delete.swiftUIKey, role: .destructive) { confirmsDeletion = true } } Spacer() if isWorking { ProgressView().controlSize(.small) } Button(AppStrings.Common.cancel.swiftUIKey, action: cancelEditing) .keyboardShortcut(.cancelAction) .disabled(isWorking) Button(existing == nil ? AppStrings.Common.import.swiftUIKey : AppStrings.Common.save.swiftUIKey) { Task { guard await commitChanges() else { return } close() } } .buttonStyle(.borderedProminent) .keyboardShortcut(.defaultAction) .disabled( isWorking || isLoading || (canEditPrivateKey && (usesWebAuthn ? reference.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty : privateKey.isEmpty)) ) } .padding() } .fileImporter( isPresented: $isChoosingFile, allowedContentTypes: [.data, .plainText], allowsMultipleSelection: false, onCompletion: receiveFiles ) .overlay { if nativeDropKind != nil { DropTargetOverlay(presentation: dropPresentation) } } .overlay { NativeDropTarget( targetedKind: $nativeDropKind, acceptedKinds: [.files], isEnabled: canEditPrivateKey && !isLoading && !isWorking, receive: { payload in receive(urls: payload.fileURLs) } ) } .animation( Motion.dropTarget(reduceMotion: reduceMotion), value: nativeDropKind ) .task { load() editingToken = editingSession.register( route: editingRoute, isDirty: { isDirty }, isBusy: { isWorking } ) { await commitChanges() } } .onChange(of: reference) { _, value in if let credential = try? WebAuthnSSH.importReference(value) { verification = credential.userVerification } } .onChange(of: privateKey) { old, value in guard canEditPrivateKey, !isLoading else { return } replacementDraft.privateKeyChanged( from: old, to: value, originalPrivateKey: originalPrivateKey, originalPublicKey: existing?.publicKey ?? "", originalPassphrase: originalPassphrase, publicKey: &publicKey, passphrase: &passphrase) } .onDisappear { referenceDropTask?.cancel() privateKey = "" originalPrivateKey = "" passphrase = "" originalPassphrase = "" replacementDraft = SSHKeyReplacementDraft() material = nil isLoading = loadPrivateKey != nil guard let editingToken else { return } editingSession.unregister(editingToken) } .confirmationDialog( AppStrings.Keychain.deleteKeyConfirmation, isPresented: $confirmsDeletion, titleVisibility: .visible ) { Button(AppStrings.Keychain.deleteKey.swiftUIKey, role: .destructive, action: deleteKey) Button(AppStrings.Common.cancel.swiftUIKey, role: .cancel) {} } message: { Text(AppStrings.Keychain.deleteKeyWarning) } .task(id: validationToken) { refreshPublicMaterial() } .task(id: CertificateValidationInput(certificate: certificate, publicKey: publicKey)) { certificateWarning = validateCertificate() } } /// A key blob field drawn on the form's own background. /// /// `TextEditor` brings its own opaque control background, which inside a /// grouped `Form` reads as a white card sitting on the section rather than /// as part of it. Hiding it puts these two fields on the same footing as the /// skill editor's, which is the surface this one is measured against. /// /// The prompt is the price of that: `TextEditor` has no `prompt:`, and once /// the box is gone an empty field is an unmarked gap in the form instead of /// a visibly empty box. It matches the editor's font so the first typed /// character lands exactly where the prompt started. private func keyTextEditor( text: Binding, minHeight: CGFloat, placeholder: LocalizedStringResource ) -> some View { TextEditor(text: text) .autohidingScrollers() .font(.caption.monospaced()) .frame(minHeight: minHeight) .scrollContentBackground(.hidden) .overlay(alignment: .topLeading) { if text.wrappedValue.isEmpty { Text(placeholder) .font(.caption.monospaced()) .foregroundStyle(.tertiary) .padding(.leading, Self.textInset) .allowsHitTesting(false) } } } /// `TextEditor`'s own text-container inset, which the prompt has to match or /// the first typed character lands somewhere else than the prompt did. /// /// Only leading: the container is inset by its line fragment padding /// horizontally and by nothing vertically, so the top padding this used to /// carry pushed the prompt a line below the caret it was standing in for. /// Same value the composer prompt in `ConversationViews` uses. private static let textInset: CGFloat = 5 /// The section title with its file picker at the trailing edge. /// /// The button used to head a row of its own, next to a label repeating the /// section title. With the editor no longer drawn as a box, that row had /// nothing left to attach to and read as a second heading. private func fileSectionHeader( _ title: LocalizedStringResource, canChoose: Bool = true, choose: @escaping () -> Void ) -> some View { HStack { Text(title) Spacer() Button(AppStrings.Common.choose.swiftUIKey, action: choose) .disabled(!canChoose) } } @MainActor private var isDirty: Bool { guard let existing else { return !name.isEmpty || !privateKey.isEmpty || !publicKey.isEmpty || !certificate.isEmpty || colorTag != .none || storage != initialStorage || !passphrase.isEmpty || !savePassphrase || usesWebAuthn || !reference.isEmpty || verification != .preferred } return name != existing.label || colorTag != existing.colorTag || storage != existing.storage || privateKey != originalPrivateKey || passphrase != originalPassphrase || savePassphrase != originalSavePassphrase || publicKey != existing.publicKey || certificate != existing.certificate } private func cancelEditing() { guard !isWorking else { return } editingSession.requestCancellation(route: editingRoute, action: close) } private var editingRoute: VaultWorkRoute { existing.map { .editKey($0.id) } ?? .importKey } private func commitChanges() async -> Bool { guard !isWorking, !isLoading else { return false } guard existing == nil || isDirty else { return true } isWorking = true errorMessage = nil do { if usesWebAuthn, let saveSecurityKey { var credential = try WebAuthnSSH.importReference(reference) credential.userVerification = verification try WebAuthnSSH.validate(credential) try saveSecurityKey(name, colorTag, credential) isWorking = false return true } let normalizedCertificate = certificate.trimmingCharacters(in: .whitespacesAndNewlines) if !canEditPrivateKey, existing != nil, let saveMetadata { if !normalizedCertificate.isEmpty { _ = try SSHUserCertificate.parse( normalizedCertificate, matching: publicKey.isEmpty ? nil : publicKey) } _ = try saveMetadata( name.trimmingCharacters(in: .whitespacesAndNewlines), colorTag, normalizedCertificate, publicKey ) isWorking = false return true } let analysis = try SSHKeyMaterial.analyzeImport(privateKey: privateKey, publicKey: publicKey) let inspected = try analysis.material.get() let requiresPassphrase = analysis.metadata.isEncrypted let passphrase = requiresPassphrase && !passphrase.isEmpty ? passphrase : nil let savesPassphrase = savePassphrase && passphrase != nil if !normalizedCertificate.isEmpty { _ = try SSHUserCertificate.parse( normalizedCertificate, matching: inspected.publicKey.isEmpty ? nil : inspected.publicKey ) } _ = try await save( name.trimmingCharacters(in: .whitespacesAndNewlines), colorTag, storage, privateKey, passphrase, savesPassphrase, normalizedCertificate, inspected ) savePassphrase = savesPassphrase isWorking = false return true } catch { errorMessage = error.localizedDescription isWorking = false return false } } private func deleteKey() { do { try delete?() close() } catch { errorMessage = error.localizedDescription } } private struct MaterialValidationInput: Hashable { let privateKey: String let publicKey: String } private struct CertificateValidationInput: Hashable { let certificate: String let publicKey: String } private var validationToken: MaterialValidationInput { MaterialValidationInput(privateKey: privateKey, publicKey: publicKey) } private var defaultName: String { if usesWebAuthn { return String(localized: AppStrings.SecurityKey.title) } let algorithm = material?.privateKey == privateKey ? material?.algorithm : privateKey == originalPrivateKey ? existing?.algorithm : nil guard let algorithm else { return "" } return SSHKeyRef.defaultName(algorithm: algorithm, storage: storage) } private var dropPresentation: DropTargetPresentation { DropTargetPresentation( title: existing == nil ? String(localized: AppStrings.Common.dragToImport) : String(localized: AppStrings.Keychain.dragToReplace), detail: String( localized: usesWebAuthn ? AppStrings.SecurityKey.reference : AppStrings.Keychain.privateKeyOrCertificate ), systemImage: "key.fill" ) } private func validateCertificate() -> String? { guard !certificate.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil } do { return try SSHUserCertificate.parse( certificate, matching: publicKey.isEmpty ? nil : publicKey ).validityWarning } catch { return error.localizedDescription } } @MainActor private func load() { guard isLoading else { return } do { let secret = try loadPrivateKey?() privateKey = secret?.privateKey ?? "" originalPrivateKey = privateKey passphrase = secret?.passphrase ?? "" originalPassphrase = passphrase savePassphrase = !passphrase.isEmpty originalSavePassphrase = savePassphrase } catch { errorMessage = error.localizedDescription canEditPrivateKey = false privateKey = "" originalPrivateKey = "" } isLoading = false } private func refreshPublicMaterial() { let analysis = try? SSHKeyMaterial.analyzeImport(privateKey: privateKey, publicKey: publicKey) privateKeyRequiresPassphrase = analysis?.metadata.isEncrypted == true material = try? analysis?.material.get() } private func receiveReferenceText(_ providers: [NSItemProvider]) -> Bool { guard usesWebAuthn, !isWorking, let provider = providers.first else { return false } referenceDropTask?.cancel() referenceDropTask = Task { do { let text = try await ReferenceDropLoader.load(provider) _ = try WebAuthnSSH.importReference(text) try Task.checkCancellation() guard usesWebAuthn, !isWorking else { return } reference = text errorMessage = nil } catch is CancellationError { } catch { errorMessage = error.localizedDescription } } return true } private func receiveFiles(_ result: Result<[URL], any Error>) { do { guard let url = try result.get().first else { return } if usesWebAuthn { reference = try WebAuthnSSH.readReferenceFile(url) errorMessage = nil return } let imported = try SoftwareKeyFileImporter.read( urls: [url], infersName: fileChoice == .privateKey && SoftwareKeyFileImporter.shouldInferName(from: name) ) switch fileChoice { case .privateKey: guard let importedPrivateKey = imported.privateKey else { throw SoftwareKeyFileImporter.unexpectedFileError() } privateKey = importedPrivateKey if let inferredName = imported.inferredName { name = inferredName } case .certificate: guard let importedCertificate = imported.certificate else { throw SoftwareKeyFileImporter.unexpectedFileError() } certificate = importedCertificate } errorMessage = nil } catch { errorMessage = error.localizedDescription } } private func chooseFile(for choice: FileChoice) { fileChoice = choice isChoosingFile = true } @discardableResult private func receive(urls: [URL]) -> Bool { do { guard !urls.isEmpty else { return false } if usesWebAuthn, let url = urls.first { reference = try WebAuthnSSH.readReferenceFile(url) errorMessage = nil return true } let result = try SoftwareKeyFileImporter.read( urls: urls, infersName: SoftwareKeyFileImporter.shouldInferName(from: name) ) if let importedPrivateKey = result.privateKey { privateKey = importedPrivateKey } if let importedCertificate = result.certificate { certificate = importedCertificate } if let inferredName = result.inferredName { name = inferredName } errorMessage = nil return result.privateKey != nil || result.certificate != nil } catch { errorMessage = error.localizedDescription return false } } } struct KeychainGenerateDetail: View { @Environment(AppModel.self) private var app @Environment(WindowModel.self) private var window var body: some View { GenerateKeySheet( credentials: app.credentials, defaultStorage: app.newVaultKeyStorage, generate: generate, selectKey: { window.selectVaultItem(.key($0.id), on: .keychain) }, close: window.closeVaultWork) } private func generate( label: String, colorTag: TagColor, generation: SSHKeyMaterial.Generation, passphrase: String?, savePassphrase: Bool, cipher: SSHKeyMaterial.Cipher, rounds: Int, storage: KeyStorage, requiresUserPresence: Bool ) async throws -> SSHKeyRef { let comment = "termind@\(ProcessInfo.processInfo.hostName)" let key: SSHKeyRef if storage == .secureEnclave { let material = try await Task.detached(priority: .userInitiated) { try SecureEnclaveKey.generate( comment: comment, requiresUserPresence: requiresUserPresence ) }.value key = SSHKeyRef( label: label, algorithm: .ecdsa256, publicKey: material.publicKey, fingerprint: material.fingerprint, credentialRef: "termind.key.\(UUID().uuidString)", storage: .secureEnclave, keySize: 256, hasPassphrase: false, requiresUserPresence: requiresUserPresence, createdAt: Date(), colorTag: colorTag ) try app.credentials.upsert(key, credential: .secureEnclave(material.credential)) } else { let material = try await Task.detached(priority: .userInitiated) { try SSHKeyMaterial.generate( generation, comment: comment, passphrase: passphrase, cipher: cipher, rounds: rounds ) }.value key = SSHKeyRef( label: label, algorithm: material.algorithm, publicKey: material.publicKey, fingerprint: material.fingerprint, credentialRef: "termind.key.\(UUID().uuidString)", storage: storage, keySize: material.keySize, hasPassphrase: passphrase != nil, createdAt: Date(), colorTag: colorTag ) try app.credentials.upsert( key, credential: .software( SSHPrivateKeySecret(privateKey: material.privateKey, passphrase: savePassphrase ? passphrase : nil) ) ) } window.selectVaultItem(.key(key.id), on: .keychain) return key } } private struct GenerateKeySheet: View { private enum Kind: String, CaseIterable, Identifiable { case ed25519 = "Ed25519" case ecdsa = "ECDSA" case rsa = "RSA" var id: String { rawValue } } let credentials: CredentialLibraryModel let initialStorage: KeyStorage let selectKey: (SSHKeyRef) -> Void @State private var usesWebAuthn = false @State private var verification: SecurityKeyCredential.UserVerification = .preferred @State private var presenter = SecurityKeyPresenter() @State private var generationTask: Task? let generate: ( String, TagColor, SSHKeyMaterial.Generation, String?, Bool, SSHKeyMaterial.Cipher, Int, KeyStorage, Bool ) async throws -> SSHKeyRef let close: () -> Void @State private var label = "" @State private var colorTag = TagColor.none @State private var storage: KeyStorage @State private var kind = Kind.ed25519 @State private var curveBits = 256 @State private var rsaBits = 3072 @State private var passphrase = "" @State private var savePassphrase = true @State private var confirmation = "" @State private var cipher = SSHKeyMaterial.Cipher.aes256 @State private var rounds = 100 @State private var requiresUserPresence = false @State private var isWorking = false @State private var errorMessage: String? @State private var editingToken: UUID? @Environment(VaultWorkEditingSession.self) private var editingSession init( credentials: CredentialLibraryModel, defaultStorage: KeyStorage = .iCloud, generate: @escaping ( String, TagColor, SSHKeyMaterial.Generation, String?, Bool, SSHKeyMaterial.Cipher, Int, KeyStorage, Bool ) async throws -> SSHKeyRef, selectKey: @escaping (SSHKeyRef) -> Void, close: @escaping () -> Void ) { self.credentials = credentials initialStorage = defaultStorage self.selectKey = selectKey self.generate = generate self.close = close _storage = State(initialValue: defaultStorage) } private var generation: SSHKeyMaterial.Generation { if storage == .secureEnclave { return .ecdsa(bits: 256) } return switch kind { case .ed25519: .ed25519 case .ecdsa: .ecdsa(bits: curveBits) case .rsa: .rsa(bits: rsaBits) } } private var passwordsMatch: Bool { passphrase.isEmpty || passphrase == confirmation } private var isDirty: Bool { !label.isEmpty || colorTag != .none || storage != initialStorage || kind != .ed25519 || curveBits != 256 || rsaBits != 3072 || !passphrase.isEmpty || !confirmation.isEmpty || !savePassphrase || cipher != .aes256 || rounds != 100 || requiresUserPresence || usesWebAuthn || verification != .preferred } private var canGenerate: Bool { !isWorking && (usesWebAuthn || (storage == .secureEnclave ? SecureEnclaveKey.isAvailable : passwordsMatch && (passphrase.isEmpty || rounds > 0))) } private func cancelEditing() { guard !isWorking else { return } editingSession.requestCancellation(route: .generateKey, action: close) } var body: some View { VaultDetailScaffold(title: AppStrings.Keychain.generateKey, symbol: "key.fill", close: cancelEditing) { Form { Section { VaultNameField(text: $label, prompt: Text(defaultName)) VaultColorField(selection: $colorTag) } Section( !usesWebAuthn && storage == .secureEnclave ? AppStrings.Keychain.secureEnclave.swiftUIKey : AppStrings.Keychain.privateKey.swiftUIKey ) { KeyCreationStoragePicker( storage: $storage, usesWebAuthn: $usesWebAuthn, allowsSecureEnclave: true, secureEnclaveAvailable: SecureEnclaveKey.isAvailable ) if usesWebAuthn { WebAuthnVerificationPicker(selection: $verification) } else if storage == .secureEnclave { LabeledContent(AppStrings.Keychain.algorithm.swiftUIKey, value: "ECDSA") LabeledContent( AppStrings.Keychain.ellipticCurve.swiftUIKey, value: String(localized: AppStrings.Keychain.nistP256)) Toggle(AppStrings.Keychain.requireAuthentication.swiftUIKey, isOn: $requiresUserPresence) } else { Picker(AppStrings.Keychain.algorithm.swiftUIKey, selection: $kind) { ForEach(Kind.allCases) { Text($0.rawValue).tag($0) } } if kind == .ecdsa { Picker(AppStrings.Keychain.ellipticCurve.swiftUIKey, selection: $curveBits) { Text(AppStrings.Keychain.nistP256).tag(256) Text(AppStrings.Keychain.nistP384).tag(384) Text(AppStrings.Keychain.nistP521).tag(521) } } else if kind == .rsa { Picker(AppStrings.Keychain.keySize.swiftUIKey, selection: $rsaBits) { Text(AppStrings.Keychain.rsa2048Bits).tag(2048) Text(AppStrings.Keychain.rsa3072Bits).tag(3072) Text(AppStrings.Keychain.rsa4096Bits).tag(4096) } } RevealableSecretField( AppStrings.Keychain.passphrase, text: $passphrase, prompt: Text(AppStrings.Common.optional)) RevealableSecretField(AppStrings.Keychain.confirmPassphrase, text: $confirmation) .disabled(passphrase.isEmpty) Toggle(AppStrings.Keychain.savePassphrase.swiftUIKey, isOn: $savePassphrase) if !passphrase.isEmpty { Picker(AppStrings.Keychain.cipher.swiftUIKey, selection: $cipher) { ForEach(SSHKeyMaterial.Cipher.allCases) { cipher in Text(cipher.displayName).tag(cipher) } } TextField(AppStrings.Keychain.rounds.swiftUIKey, value: $rounds, format: .number) .lineLimit(1) } if !passwordsMatch { Text(AppStrings.Keychain.passphrasesDoNotMatch) .font(.caption) .foregroundStyle(.red) } } } if let errorMessage { Label(errorMessage, systemImage: "exclamationmark.triangle.fill") .foregroundStyle(.orange) } } .formStyle(.grouped) .disabled(isWorking) } footer: { HStack { Spacer() if isWorking { ProgressView().controlSize(.small) } Button(AppStrings.Common.cancel.swiftUIKey, action: cancelEditing) .keyboardShortcut(.cancelAction) .disabled(isWorking) Button(AppStrings.Keychain.generate.swiftUIKey) { generationTask = Task { if await commitChanges() { close() } } } .keyboardShortcut(.defaultAction) .disabled(!canGenerate) } .padding() } .background(SecurityKeyAnchor(presenter: presenter).frame(width: 0, height: 0)) .onAppear { editingToken = editingSession.register( route: .generateKey, isDirty: { isDirty }, isBusy: { isWorking } ) { await commitChanges() } } .onDisappear { if let editingToken { editingSession.unregister(editingToken) } generationTask?.cancel() presenter.cancel() passphrase = "" confirmation = "" } } @MainActor private func commitChanges() async -> Bool { guard !isWorking else { return false } guard canGenerate else { errorMessage = String(localized: AppStrings.Common.completeAllRequiredFieldsBeforeSaving) return false } isWorking = true defer { isWorking = false } errorMessage = nil do { if usesWebAuthn { let credential = try await presenter.register( name: label.isEmpty ? "Termind" : label, userVerification: verification) try WebAuthnSSH.validate(credential) try Task.checkCancellation() var key = try WebAuthnSSH.keyReference( credential, label: label) key.colorTag = colorTag try credentials.upsert(key) selectKey(key) } else { let savesPassphrase = savePassphrase && !passphrase.isEmpty _ = try await generate( label.trimmingCharacters(in: .whitespacesAndNewlines), colorTag, generation, storage == .secureEnclave || passphrase.isEmpty ? nil : passphrase, savesPassphrase, cipher, rounds, storage, storage == .secureEnclave && requiresUserPresence ) savePassphrase = savesPassphrase } passphrase = "" confirmation = "" return true } catch { errorMessage = error.localizedDescription return false } } private var defaultName: String { if usesWebAuthn { return String(localized: AppStrings.SecurityKey.title) } let algorithm: SSHKeyAlgorithm = switch kind { case .ed25519: .ed25519 case .ecdsa: .ecdsa256 case .rsa: .rsa } return SSHKeyRef.defaultName(algorithm: algorithm, storage: storage) } } struct KeychainEditDetail: View { let keyID: SSHKeyRef.ID @Environment(AppModel.self) private var app @Environment(WindowModel.self) private var window @ViewBuilder var body: some View { if let key = app.credentials.key(keyID) { if key.algorithm == .securityKey { SecurityKeyEditor(credentials: app.credentials, existing: key, close: window.closeVaultWork) } else if key.storage == .secureEnclave { SecureEnclaveKeyEditor( key: key, close: window.closeVaultWork, delete: { try delete(key) }, save: { name, colorTag in try saveSecureEnclave(key, name: name, colorTag: colorTag) } ) } else { SoftwareKeyEditor( existing: key, title: AppStrings.Keychain.editKey, symbol: "pencil", close: window.closeVaultWork, loadPrivateKey: { try loadPrivateKey(key) }, saveMetadata: { name, colorTag, certificate, publicKey in try saveSoftwareKeyMetadata( key, name: name, colorTag: colorTag, certificate: certificate, publicKey: publicKey ) }, delete: { try delete(key) }, save: { name, colorTag, storage, privateKey, passphrase, savePassphrase, certificate, material in try await saveSoftwareKey( key, name: name, colorTag: colorTag, storage: storage, privateKey: privateKey, passphrase: passphrase, savePassphrase: savePassphrase, certificate: certificate, material: material ) } ) } } else { ContentUnavailableView(AppStrings.Keychain.keyUnavailable.swiftUIKey, systemImage: "key.slash") } } private func loadPrivateKey(_ key: SSHKeyRef) throws -> SSHPrivateKeySecret { guard case .software(let secret)? = try app.credentials.keyCredential(for: key) else { throw TermindSSHError.library( String(localized: AppStrings.Keychain.thePrivateKeyIsUnavailableOnThisMac) ) } return secret } private func delete(_ key: SSHKeyRef) throws { try app.credentials.delete(keyID: key.id) window.pruneVaultSelections(in: app.credentials) } private func saveSoftwareKey( _ original: SSHKeyRef, name: String, colorTag: TagColor, storage: KeyStorage, privateKey: String, passphrase: String?, savePassphrase: Bool, certificate: String, material: SSHKeyMaterial ) async throws -> SSHKeyRef { var updated = original updated.label = name updated.colorTag = colorTag updated.storage = storage updated.algorithm = material.algorithm updated.publicKey = material.publicKey updated.certificate = certificate updated.fingerprint = material.fingerprint updated.keySize = material.keySize updated.hasPassphrase = try SSHKeyMaterial.privateKeyMetadata(privateKey).isEncrypted try app.credentials.upsert( updated, credential: .software( SSHPrivateKeySecret(privateKey: privateKey, passphrase: savePassphrase ? passphrase : nil) ) ) window.selectVaultItem(.key(updated.id), on: .keychain) return updated } private func saveSoftwareKeyMetadata( _ original: SSHKeyRef, name: String, colorTag: TagColor, certificate: String, publicKey: String ) throws -> SSHKeyRef { var updated = original updated.label = name updated.colorTag = colorTag if publicKey != original.publicKey { let material = publicKey.isEmpty ? nil : try SSHKeyMaterial.publicMaterial(publicKey) updated.publicKey = material?.publicKey ?? "" updated.fingerprint = material?.fingerprint ?? "" updated.algorithm = material?.algorithm ?? updated.algorithm updated.keySize = material?.keySize ?? updated.keySize } updated.certificate = certificate try app.credentials.upsert(updated) window.selectVaultItem(.key(updated.id), on: .keychain) return updated } private func saveSecureEnclave( _ original: SSHKeyRef, name: String, colorTag: TagColor ) throws { var updated = original updated.label = name updated.colorTag = colorTag try app.credentials.upsert(updated) window.selectVaultItem(.key(updated.id), on: .keychain) } } private struct SecureEnclaveKeyEditor: View { let key: SSHKeyRef let close: () -> Void let delete: () throws -> Void let save: (String, TagColor) throws -> Void @State private var name: String @State private var colorTag: TagColor @State private var errorMessage: String? @State private var confirmsDeletion = false @State private var editingToken: UUID? @Environment(VaultWorkEditingSession.self) private var editingSession init( key: SSHKeyRef, close: @escaping () -> Void, delete: @escaping () throws -> Void, save: @escaping (String, TagColor) throws -> Void ) { self.key = key self.close = close self.delete = delete self.save = save _name = State(initialValue: key.label) _colorTag = State(initialValue: key.colorTag) } var body: some View { VaultDetailScaffold( title: AppStrings.Keychain.editKey, symbol: "pencil", close: cancelEditing, closeLabel: AppStrings.Common.cancel ) { Form { Section { VaultNameField(text: $name, prompt: Text(verbatim: key.defaultName)) VaultColorField(selection: $colorTag) } Section(AppStrings.Keychain.secureEnclave.swiftUIKey) { LabeledContent(AppStrings.Keychain.storeIn.swiftUIKey) { Label(key.storage.displayName, systemImage: key.storage.symbol) } LabeledContent(AppStrings.Keychain.algorithm.swiftUIKey, value: key.algorithm.displayName) LabeledContent(AppStrings.KnownHosts.fingerprint.swiftUIKey) { Text(key.fingerprint).font(.caption.monospaced()).textSelection(.enabled) } LabeledContent(AppStrings.Keychain.publicKey.swiftUIKey) { Text(key.publicKey).font(.caption.monospaced()).textSelection(.enabled) } } if let errorMessage { Section { Label(errorMessage, systemImage: "exclamationmark.triangle.fill") } } } .formStyle(.grouped) } footer: { HStack(spacing: Metrics.Space.m) { Button(AppStrings.Common.delete.swiftUIKey, role: .destructive) { confirmsDeletion = true } Spacer() Button(AppStrings.Common.cancel.swiftUIKey, action: cancelEditing) Button(AppStrings.Common.save.swiftUIKey, action: saveAndClose) .buttonStyle(.borderedProminent) .keyboardShortcut(.defaultAction) } .padding() } .onAppear { editingToken = editingSession.register( route: .editKey(key.id), isDirty: { isDirty } ) { commitChanges() } } .onDisappear { guard let editingToken else { return } editingSession.unregister(editingToken) } .confirmationDialog( AppStrings.Keychain.deleteKeyConfirmation, isPresented: $confirmsDeletion, titleVisibility: .visible ) { Button(AppStrings.Keychain.deleteKey.swiftUIKey, role: .destructive, action: deleteKey) Button(AppStrings.Common.cancel.swiftUIKey, role: .cancel) {} } message: { Text(AppStrings.Keychain.deleteKeyWarning) } } private var isDirty: Bool { name != key.label || colorTag != key.colorTag } private func cancelEditing() { editingSession.requestCancellation(route: .editKey(key.id), action: close) } private func commitChanges() -> Bool { guard isDirty else { return true } do { try save(name.trimmingCharacters(in: .whitespacesAndNewlines), colorTag) errorMessage = nil return true } catch { errorMessage = error.localizedDescription return false } } private func saveAndClose() { guard commitChanges() else { return } close() } private func deleteKey() { do { try delete() close() } catch { errorMessage = error.localizedDescription } } } // MARK: - Export to hosts /// Installs one or more public keys on several saved destinations. A checked row is an /// explicit authorization to change that account's `authorized_keys`; opening /// the sheet never inherits the Keychain card selection as a host selection. // MARK: - Key card /// The singular actions for one key, shared by its card and editor surfaces. /// Card actions never resolve secret material; Edit is the explicit boundary /// that loads a software key from Keychain. struct KeyObjectActions: View { let key: SSHKeyRef let edit: () -> Void let export: () -> Void var body: some View { Button(AppStrings.Keychain.exportToHost.swiftUIKey, systemImage: MenuSymbol.export, action: export) Divider() Menu(AppStrings.Common.copy.swiftUIKey, systemImage: MenuSymbol.copy) { Button(AppStrings.Keychain.publicKey.swiftUIKey, systemImage: "key") { copyToPasteboard(key.publicKey) } .disabled(key.publicKey.isEmpty) Button(AppStrings.KnownHosts.fingerprint.swiftUIKey, systemImage: "touchid") { copyToPasteboard(key.fingerprint) } .disabled(key.publicKey.isEmpty || key.fingerprint.isEmpty) } Button(AppStrings.Common.edit.swiftUIKey, systemImage: MenuSymbol.edit, action: edit) } } private struct KeyCard: View { let key: SSHKeyRef let isSelected: Bool let select: () -> Void let extendSelection: () -> Void let actionKeys: [SSHKeyRef] let actionIdentities: [Identity] let edit: () -> Void let export: () -> Void let delete: () -> Void @Environment(AppModel.self) private var app /// Deleting a key breaks every identity pointing at it, so the card says how /// many that is before anyone finds out the hard way. private var dependents: Int { app.credentials.identities(using: key.id).count } var body: some View { CardTile( selectionID: VaultSelectionID.key(key.id), isSelected: isSelected, select: select, extendSelection: extendSelection ) { VStack(alignment: .leading, spacing: Metrics.Space.m) { HStack(spacing: Metrics.Space.m) { CardIcon( symbol: "key.fill", tint: key.colorTag.color ?? TermindColor.accent ) VStack(alignment: .leading, spacing: 1) { Text(key.label) .font(.headline) .lineLimit(1) Text( key.algorithm == .unknown ? AppStrings.Keychain.unknownKeyType : AppStrings.Keychain.keyType(keyDisplayName) ) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) } .frame(maxWidth: .infinity, alignment: .leading) } HStack(spacing: Metrics.Space.xs) { if key.hasPassphrase { CardChip( text: String( localized: app.credentials.hasSavedPassphrase(for: key) ? AppStrings.Keychain.passphraseSaved : AppStrings.Keychain.passphraseRequired), symbol: "lock.fill" ) } CardChip( text: key.algorithm == .securityKey ? String(localized: AppStrings.SecurityKey.title) : key.storage.displayName, symbol: key.algorithm == .securityKey ? "key.radiowaves.forward.fill" : key.storage.symbol ) switch app.credentials.keyAvailability(for: key) { case .missing: CardChip( text: String(localized: AppStrings.Keychain.privateKeyMissing), symbol: "exclamationmark.triangle.fill", tint: .orange ) case .failed: CardChip( text: String(localized: AppStrings.Keychain.keychainUnavailable), symbol: "exclamationmark.triangle.fill", tint: .orange ) case .available, .notConfigured: EmptyView() } Spacer(minLength: Metrics.Space.xs) if dependents > 0 { Text(AppStrings.Keychain.identityCount(dependents)) .font(.caption2) .foregroundStyle(.tertiary) .lineLimit(1) } } } } .contextMenu { if actionKeys.count == 1, actionIdentities.isEmpty { KeyObjectActions(key: key, edit: edit, export: export) } Divider() DestructiveMenuButton(AppStrings.Common.delete, action: delete) } .help(Text(key.fingerprint)) } private var keyDisplayName: String { if key.algorithm == .rsa, let bits = key.keySize { return "RSA \(bits)" } return key.algorithm.displayName } } // MARK: - Identity card struct IdentityObjectActions: View { let edit: () -> Void var body: some View { Button(AppStrings.Common.edit.swiftUIKey, systemImage: MenuSymbol.edit, action: edit) } } private struct IdentityCard: View { let identity: Identity let isSelected: Bool let select: () -> Void let extendSelection: () -> Void let actionKeys: [SSHKeyRef] let actionIdentities: [Identity] let edit: () -> Void let delete: () -> Void @Environment(AppModel.self) private var app /// The key this identity signs with, resolved through the store so renaming a /// key renames it here too. private var key: SSHKeyRef? { identity.keyID.flatMap { app.credentials.key($0) } } var body: some View { CardTile( selectionID: VaultSelectionID.identity(identity.id), isSelected: isSelected, select: select, extendSelection: extendSelection ) { VStack(alignment: .leading, spacing: Metrics.Space.m) { HStack(spacing: Metrics.Space.m) { CardIcon( symbol: "person.badge.key.fill", tint: identity.colorTag.color ?? .accentColor ) VStack(alignment: .leading, spacing: 1) { Text(identity.name) .font(.headline) .lineLimit(1) Text(identity.username) .font(.caption.monospaced()) .foregroundStyle(.secondary) .lineLimit(1) } Spacer(minLength: Metrics.Space.xs) } HStack(spacing: Metrics.Space.xs) { secretChip Spacer(minLength: Metrics.Space.xs) Text( modifiedAt .map { $0.formatted(.relative(presentation: .numeric)) } ?? String(localized: AppStrings.SFTP.unknown) ) .font(.caption2) .foregroundStyle(.tertiary) .lineLimit(1) } } } .contextMenu { if actionKeys.isEmpty, actionIdentities.count == 1 { IdentityObjectActions(edit: edit) } Divider() DestructiveMenuButton(AppStrings.Common.delete, action: delete) } .help(Text(identity.notes.isEmpty ? identity.username : identity.notes)) } private var modifiedAt: Date? { guard let date = app.credentials.identityModifiedAt(identity.id), date > VaultVersion.baselineDate else { return nil } return date } /// One chip per secret the identity actually has — so a login with both /// shows both, rather than the card picking a winner and hiding the other. /// /// A dangling key reference gets its own chip rather than disappearing: an /// identity that quietly stopped signing with a key is exactly the thing you /// want to find out about here and not at connect time. Neither secret is a /// legal state too, and it says so — an unfinished identity should look /// unfinished. @ViewBuilder private var secretChip: some View { if identity.usesPassword { switch app.credentials.passwordAvailability(for: identity) { case .available: CardChip(text: String(localized: AppStrings.Vault.password), symbol: "lock.fill") case .missing: CardChip( text: String(localized: AppStrings.Keychain.passwordMissing), symbol: "exclamationmark.triangle.fill", tint: .orange ) case .failed: CardChip( text: String(localized: AppStrings.Keychain.passwordUnavailable), symbol: "exclamationmark.triangle.fill", tint: .orange ) case .notConfigured: EmptyView() } CardChip( text: identity.passwordStorage.displayName, symbol: identity.passwordStorage.symbol ) } if identity.usesKey { if let key { CardChip( text: key.label, symbol: "key.fill", tint: key.colorTag.color ?? TermindColor.accent ) } else { CardChip( text: String(localized: AppStrings.Keychain.keyMissing), symbol: "exclamationmark.triangle.fill", tint: .orange ) } } if !identity.hasSecret { CardChip(text: String(localized: AppStrings.Keychain.noSecret), symbol: "questionmark.circle") } } }