Files
Termind/TFITests/MobileKeyExportTests.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())
}
}