168 lines
8.5 KiB
Swift
168 lines
8.5 KiB
Swift
// SPDX-FileCopyrightText: 2026 Kaede Akino
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
import Foundation
|
|
import TermindApplication
|
|
import TermindDomain
|
|
import TermindRemoteInfrastructure
|
|
import Testing
|
|
|
|
@testable import TermindIOS
|
|
|
|
@Suite @MainActor
|
|
struct MobileKeyExportTests {
|
|
@Test func encryptedImportCanSaveWithoutPassphraseOrPublicKey() async throws {
|
|
let library = HostLibraryModel()
|
|
let material = try SSHKeyMaterial.generate(.ed25519, comment: "", passphrase: "secret")
|
|
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)
|
|
#expect(key.hasPassphrase && key.publicKey.isEmpty && key.fingerprint.isEmpty)
|
|
#expect(key.algorithm == .unknown)
|
|
#expect(library.requiresPassphraseEntry(for: key))
|
|
let reopened = MobileKeyEditorModel(
|
|
mode: .edit, credentials: CredentialLibraryModel(library: library), key: key)
|
|
await reopened.loadPrivateKey()
|
|
#expect(!reopened.savePassphrase && reopened.passphrase.isEmpty)
|
|
let export = KeyExportModel(
|
|
key: key, library: library, services: RemoteConnectionCoordinator().keyExportServices())
|
|
#expect(export.needsExportPassphrase && export.authenticationPrompt == nil)
|
|
}
|
|
|
|
@Test func clearingASavedPassphraseTurnsSavingOffButKeepsTheKeyEncrypted() async throws {
|
|
let library = HostLibraryModel()
|
|
let credentials = CredentialLibraryModel(library: library)
|
|
let generator = MobileKeyEditorModel(mode: .generate, credentials: credentials)
|
|
generator.passphrase = "secret"
|
|
generator.confirmation = "secret"
|
|
try await generator.save()
|
|
let original = try #require(library.keys.first)
|
|
let editor = MobileKeyEditorModel(mode: .edit, credentials: credentials, key: original)
|
|
await editor.loadPrivateKey()
|
|
#expect(editor.savePassphrase && editor.passphrase == "secret")
|
|
editor.passphrase = ""
|
|
try await editor.save()
|
|
let saved = try #require(library.key(original.id))
|
|
#expect(saved.hasPassphrase && !editor.savePassphrase)
|
|
#expect(!credentials.hasSavedPassphrase(for: saved))
|
|
#expect(library.requiresPassphraseEntry(for: saved))
|
|
let reopened = MobileKeyEditorModel(mode: .edit, credentials: credentials, key: saved)
|
|
await reopened.loadPrivateKey()
|
|
#expect(!reopened.savePassphrase && reopened.passphrase.isEmpty)
|
|
}
|
|
|
|
@Test func manuallyEnteredPublicKeyCanBeChangedAndClearedWithoutUnlocking() async throws {
|
|
let library = HostLibraryModel()
|
|
let credentials = CredentialLibraryModel(library: library)
|
|
let material = try SSHKeyMaterial.generate(.ed25519, comment: "", passphrase: "secret")
|
|
let importer = MobileKeyEditorModel(mode: .importKey, credentials: credentials)
|
|
importer.privateKey = material.privateKey
|
|
importer.publicKey = material.publicKey
|
|
try await importer.save()
|
|
let original = try #require(library.keys.first)
|
|
#expect(original.publicKey == material.publicKey && original.fingerprint == material.fingerprint)
|
|
let export = KeyExportModel(
|
|
key: original, library: library, services: RemoteConnectionCoordinator().keyExportServices())
|
|
#expect(!export.needsExportPassphrase)
|
|
let editor = MobileKeyEditorModel(mode: .edit, credentials: credentials, key: original)
|
|
await editor.loadPrivateKey()
|
|
editor.publicKey = "malformed"
|
|
await #expect(throws: (any Error).self) { try await editor.save() }
|
|
#expect(library.key(original.id) == original)
|
|
editor.publicKey = ""
|
|
try await editor.save()
|
|
let cleared = try #require(library.key(original.id))
|
|
#expect(cleared.publicKey.isEmpty && cleared.fingerprint.isEmpty && cleared.hasPassphrase)
|
|
}
|
|
|
|
@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: credentials)
|
|
editor.privateKey = encrypted.privateKey
|
|
editor.passphrase = "secret"
|
|
editor.privateKey = material.privateKey
|
|
#expect(!editor.privateKeyRequiresPassphrase)
|
|
try await editor.save()
|
|
let key = try #require(library.keys.first)
|
|
#expect(!key.hasPassphrase && !editor.savePassphrase)
|
|
#expect(!credentials.hasSavedPassphrase(for: key))
|
|
}
|
|
|
|
@Test func replacingPrivateKeyPreservesANewManuallyEnteredPublicKey() async throws {
|
|
let library = HostLibraryModel()
|
|
let credentials = CredentialLibraryModel(library: library)
|
|
let generator = MobileKeyEditorModel(mode: .generate, credentials: credentials)
|
|
try await generator.save()
|
|
let original = try #require(library.keys.first)
|
|
let replacement = try SSHKeyMaterial.generate(.ed25519, comment: "replacement", passphrase: "secret")
|
|
let editor = MobileKeyEditorModel(mode: .edit, credentials: credentials, key: original)
|
|
await editor.loadPrivateKey()
|
|
editor.publicKey = replacement.publicKey
|
|
editor.privateKey = replacement.privateKey
|
|
#expect(editor.publicKey == replacement.publicKey)
|
|
try await editor.save()
|
|
let saved = try #require(library.key(original.id))
|
|
#expect(saved.publicKey == replacement.publicKey && saved.fingerprint == replacement.fingerprint)
|
|
#expect(saved.hasPassphrase && library.requiresPassphraseEntry(for: saved))
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
struct MobileExportFixture {
|
|
let key: SSHKeyRef
|
|
let library: HostLibraryModel
|
|
let host: TermindDomain.Host
|
|
init(encrypted: Bool = false) throws {
|
|
let material = try SSHKeyMaterial.generate(.ed25519, comment: "", passphrase: encrypted ? "secret" : nil)
|
|
key = SSHKeyRef(
|
|
label: "Deployment Key", algorithm: encrypted ? .unknown : material.algorithm,
|
|
publicKey: encrypted ? "" : material.publicKey,
|
|
credentialRef: "key", hasPassphrase: encrypted)
|
|
let identity = Identity(name: "Deploy", username: "deploy")
|
|
host = TermindDomain.Host(name: "Production", host: "server.example.com", identityID: identity.id)
|
|
library = HostLibraryModel(hosts: [host], keys: [key], identities: [identity])
|
|
try library.upsert(key, credential: .software(SSHPrivateKeySecret(privateKey: material.privateKey)))
|
|
}
|
|
func model() -> KeyExportModel {
|
|
KeyExportModel(key: key, library: library, services: RemoteConnectionCoordinator().keyExportServices())
|
|
}
|
|
}
|