fix(keychain): refine key actions and encrypted-key passphrase controls
This commit is contained in:
1 parent
1e8e088715
commit
e8ec2df237
10 files changed
+85
-27
No files matched your search
@@ -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: {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user