Files

1855 lines
74 KiB
Swift

// 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<VaultSelectionID> {
Set(window.vaultSelection(for: .keychain))
}
private var isEmpty: Bool {
app.credentials.keys.isEmpty && app.credentials.identities.isEmpty
}
// MARK: - Page
var managedSections: [ManagedCardSection<SectionID, Item>] {
[
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<VaultSelectionID> {
ManagedCardSelection(selected: { selection }, update: setSelection)
}
var managedCapabilities: ManagedCardCapabilities<SectionID, VaultSelectionID> {
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<SectionID, Item>, isDropTarget: Bool
) -> some View {
CardSectionHeader(
title: section.title, count: section.items.count,
isDropTarget: isDropTarget
)
}
@ViewBuilder
func managedCard(
for item: Item, context: ManagedCardItemContext<Item>
) -> 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<VaultSelectionID>) {
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<Void, Never>?
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<String>, 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<Void, Never>?
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")
}
}
}