fix(keychain): refine key actions and encrypted-key passphrase controls

This commit is contained in:
Kaede Akino committed 2026-10-03 14:36:40 +08:00
1 parent 1e8e088715
commit e8ec2df237
10 files changed
+85 -27

No files matched your search

+4 -4
View File
@@ -32244,13 +32244,13 @@
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "类型:%@"
"value" : "类型 %@"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "類型:%@"
"value" : "類型 %@"
}
}
}
@@ -32267,13 +32267,13 @@
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "类型未知"
"value" : "类型 未知"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "類型未知"
"value" : "類型 未知"
}
}
}
@@ -63,6 +63,7 @@ public struct KeyExportForm: View {
if model.needsExportPassphrase {
Section {
RevealableSecretField(AppStrings.Keychain.passphrase, text: $model.exportPassphrase)
.multilineTextAlignment(.leading)
Toggle(AppStrings.Keychain.savePassphrase.swiftUIKey, isOn: $model.saveExportPassphrase)
if let error = model.passphraseError { Text(error).font(.caption).foregroundStyle(.red) }
} footer: {
+3
View File
@@ -64,6 +64,9 @@ for language in zh-Hans zh-Hant; do
]
| .[]
| select(
# Key-card type subtitles intentionally separate the label and value.
(if $entry.key == "Type %@" then sub("^(?<label>类型|類型) (?=%@)"; "\(.label)") else . end)
|
test("\\p{Han}")
and (
test("\\p{Han}[ \\t]+[A-Za-z0-9%@`]|[A-Za-z0-9%@`][ \\t]+\\p{Han}")
@@ -167,6 +167,9 @@ final class MobileKeyEditorModel: Identifiable {
}
}
var fingerprint: String? { (try? SSHKeyMaterial.publicMaterial(publicKey))?.fingerprint }
var privateKeyRequiresPassphrase: Bool {
(try? SSHKeyMaterial.privateKeyMetadata(privateKey).isEncrypted) == true
}
var isEnclave: Bool { original?.storage == .secureEnclave }
var canSelectThisDevice: Bool { original?.storage != .iCloud }
var canUseEnclave: Bool { SecureEnclaveKey.isAvailable }
@@ -239,7 +242,8 @@ final class MobileKeyEditorModel: Identifiable {
nil
) async throws {
guard canSave else { throw CocoaError(.coderInvalidValue) }
let savesPassphrase = savePassphrase && !passphrase.isEmpty
let savesPassphrase =
savePassphrase && !passphrase.isEmpty && (mode == .generate || privateKeyRequiresPassphrase)
if usesWebAuthn {
var credential: SecurityKeyCredential
if mode == .generate, let registerSecurityKey {
@@ -45,11 +45,11 @@ struct MobileCredentialsView: View {
}
Divider()
Menu(AppStrings.Common.copy.swiftUIKey, systemImage: "doc.on.doc") {
Button(AppStrings.Keychain.publicKey.swiftUIKey, systemImage: "doc.on.doc") {
Button(AppStrings.Keychain.publicKey.swiftUIKey, systemImage: "key") {
UIPasteboard.general.string = key.publicKey
}
.disabled(key.publicKey.isEmpty)
Button(AppStrings.KnownHosts.fingerprint.swiftUIKey, systemImage: "doc.on.doc") {
Button(AppStrings.KnownHosts.fingerprint.swiftUIKey, systemImage: "touchid") {
UIPasteboard.general.string = key.fingerprint
}
.disabled(key.publicKey.isEmpty || key.fingerprint.isEmpty)
+7 -5
View File
@@ -140,11 +140,13 @@ struct MobileKeyEditor: View {
.font(.caption)
.foregroundStyle(.orange)
}
RevealableSecretField(AppStrings.Keychain.passphrase, text: $model.passphrase)
.multilineTextAlignment(.leading)
.disabled(!model.canEditPrivateKey)
Toggle(AppStrings.Keychain.savePassphrase.swiftUIKey, isOn: $model.savePassphrase)
.disabled(!model.canEditPrivateKey)
if model.privateKeyRequiresPassphrase {
RevealableSecretField(AppStrings.Keychain.passphrase, text: $model.passphrase)
.multilineTextAlignment(.leading)
.disabled(!model.canEditPrivateKey)
Toggle(AppStrings.Keychain.savePassphrase.swiftUIKey, isOn: $model.savePassphrase)
.disabled(!model.canEditPrivateKey)
}
}
}
} else {
@@ -17,18 +17,20 @@ struct MobileKeyExportDetailView: View {
}
.navigationTitle(AppStrings.Keychain.exportToHost.swiftUIKey)
.toolbar {
ToolbarItem(placement: .primaryAction) {
ToolbarItem(placement: .confirmationAction) {
if model.didExport {
Button(AppStrings.Common.done.swiftUIKey) { dismiss() }
} else if case .failed = model.phase, model.journey.isPresented {
Button(AppStrings.Common.edit.swiftUIKey) { model.editOptions() }
} else if !model.journey.isPresented {
Button(AppStrings.KeyExport.exportAndAttach.swiftUIKey) { model.start() }
Button(AppStrings.KeyExport.exportAndAttach.swiftUIKey, systemImage: "checkmark") { model.start() }
.labelStyle(.iconOnly)
.disabled(!model.canStart)
} else if model.phase == .exporting {
ProgressView().accessibilityLabel(AppStrings.KeyExport.exporting.swiftUIKey)
}
}
.mobileProminentAction()
}
.onDisappear { model.cancel() }
}
@@ -47,6 +47,8 @@ struct MobileKeyExportRenderingTests {
window.endEditing(true)
model.exportPassphrase = "secret"
#expect(model.canStart)
try await Task.sleep(for: .milliseconds(400))
try capture(window, language: language, state: "ready")
}
}
+42 -4
View File
@@ -14,6 +14,7 @@ struct MobileKeyExportTests {
let editor = MobileKeyEditorModel(mode: .importKey, credentials: CredentialLibraryModel(library: library))
editor.privateKey = material.privateKey
#expect(editor.canSave && editor.savePassphrase)
#expect(editor.privateKeyRequiresPassphrase && editor.passphrase.isEmpty)
try await editor.save()
#expect(!editor.savePassphrase)
let key = try #require(library.keys.first)
@@ -75,14 +76,51 @@ struct MobileKeyExportTests {
#expect(cleared.publicKey.isEmpty && cleared.fingerprint.isEmpty && cleared.hasPassphrase)
}
@Test func typedPassphraseDoesNotMakeAnUnencryptedKeyEncrypted() async throws {
@Test(arguments: [false, true])
func passphraseControlsFollowPrivateKeyEncryptionWhileReplacingKeys(editExisting: Bool) async throws {
let fixture = try MobileExportFixture(encrypted: true)
let credentials = CredentialLibraryModel(library: fixture.library)
let editor = MobileKeyEditorModel(
mode: editExisting ? .edit : .importKey, credentials: credentials,
key: editExisting ? fixture.key : nil)
let encrypted = try #require(try credentials.keyCredential(for: fixture.key))
guard case .software(let secret) = encrypted else {
Issue.record("Expected software key")
return
}
if editExisting {
await editor.loadPrivateKey()
} else {
#expect(!editor.privateKeyRequiresPassphrase)
editor.privateKey = secret.privateKey
}
#expect(editor.privateKeyRequiresPassphrase && editor.passphrase.isEmpty)
let unencrypted = try SSHKeyMaterial.generate(.ed25519, comment: "", passphrase: nil)
editor.privateKey = unencrypted.privateKey
#expect(!editor.privateKeyRequiresPassphrase)
editor.privateKey = secret.privateKey
editor.publicKey = "invalid public key"
#expect(editor.privateKeyRequiresPassphrase)
editor.privateKey = "-----BEGIN OPENSSH PRIVATE KEY-----"
#expect(!editor.privateKeyRequiresPassphrase)
editor.privateKey = ""
#expect(!editor.privateKeyRequiresPassphrase)
}
@Test func replacingEncryptedPrivateKeyDoesNotSaveHiddenPassphrase() async throws {
let library = HostLibraryModel()
let credentials = CredentialLibraryModel(library: library)
let encrypted = try SSHKeyMaterial.generate(.ed25519, comment: "", passphrase: "secret")
let material = try SSHKeyMaterial.generate(.ed25519, comment: "", passphrase: nil)
let editor = MobileKeyEditorModel(mode: .importKey, credentials: CredentialLibraryModel(library: library))
let editor = MobileKeyEditorModel(mode: .importKey, credentials: credentials)
editor.privateKey = encrypted.privateKey
editor.passphrase = "secret"
editor.privateKey = material.privateKey
editor.passphrase = "not-encryption"
#expect(!editor.privateKeyRequiresPassphrase)
try await editor.save()
#expect(library.keys.first?.hasPassphrase == false)
let key = try #require(library.keys.first)
#expect(!key.hasPassphrase && !editor.savePassphrase)
#expect(!credentials.hasSavedPassphrase(for: key))
}
@Test func replacingPrivateKeyPreservesANewManuallyEnteredPublicKey() async throws {
+15 -9
View File
@@ -601,13 +601,15 @@ private struct SoftwareKeyEditor: View {
.font(.caption)
.foregroundStyle(.orange)
}
RevealableSecretField(
AppStrings.Keychain.passphrase, text: $passphrase,
prompt: Text(AppStrings.Common.optional)
)
.disabled(!canEditPrivateKey)
Toggle(AppStrings.Keychain.savePassphrase.swiftUIKey, isOn: $savePassphrase)
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(
@@ -836,7 +838,7 @@ private struct SoftwareKeyEditor: View {
isWorking = false
return true
}
let passphrase = passphrase.isEmpty ? nil : passphrase
let passphrase = privateKeyRequiresPassphrase && !passphrase.isEmpty ? passphrase : nil
let savesPassphrase = savePassphrase && passphrase != nil
let inspected = try SSHKeyMaterial.imported(privateKey: privateKey, publicKey: publicKey)
if !normalizedCertificate.isEmpty {
@@ -869,6 +871,10 @@ private struct SoftwareKeyEditor: View {
private var validationToken: String { privateKey + "\u{0}" + publicKey }
private var privateKeyRequiresPassphrase: Bool {
(try? SSHKeyMaterial.privateKeyMetadata(privateKey).isEncrypted) == true
}
private var defaultName: String {
if usesWebAuthn { return String(localized: AppStrings.SecurityKey.title) }
let algorithm =
@@ -1525,11 +1531,11 @@ struct KeyObjectActions: 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: MenuSymbol.copy) {
Button(AppStrings.Keychain.publicKey.swiftUIKey, systemImage: "key") {
copyToPasteboard(key.publicKey)
}
.disabled(key.publicKey.isEmpty)
Button(AppStrings.KnownHosts.fingerprint.swiftUIKey, systemImage: MenuSymbol.copy) {
Button(AppStrings.KnownHosts.fingerprint.swiftUIKey, systemImage: "touchid") {
copyToPasteboard(key.fingerprint)
}
.disabled(key.publicKey.isEmpty || key.fingerprint.isEmpty)