908 lines
44 KiB
Swift
908 lines
44 KiB
Swift
// SPDX-FileCopyrightText: 2026 Kaede Akino
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
import CryptoKit
|
|
import Foundation
|
|
import SwiftData
|
|
import TermindApplication
|
|
import TermindDomain
|
|
import TermindInfrastructure
|
|
import TermindPersistence
|
|
import TermindPresentation
|
|
import TermindRemoteInfrastructure
|
|
import Testing
|
|
|
|
@testable import TermindIOS
|
|
|
|
@Suite @MainActor
|
|
struct MobileVaultFeatureTests {
|
|
@Test(arguments: [LocalDiscoveryProtocol.ssh, .telnet])
|
|
func discoveryUsesTheSelectedProtocolWithoutRequestingPro(_ connectionProtocol: LocalDiscoveryProtocol) throws {
|
|
var dependencies = MobileAppDependencies.testing()
|
|
dependencies.proAccess = .testing(status: .free)
|
|
let app = try MobileAppModel(dependencies: dependencies)
|
|
defer { app.outbox.stop() }
|
|
let model = app.localDiscoveryModel(protocol: connectionProtocol)
|
|
#expect(model.connectionProtocol == connectionProtocol)
|
|
#expect(model.phase == .idle)
|
|
#expect(!app.pro.isPaywallPresented)
|
|
}
|
|
|
|
@Test func webAuthnGenerationUsesOriginalEditorAndPreferredVerification() async throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
defer { app.outbox.stop() }
|
|
let editor = MobileKeyEditorModel(mode: .generate, credentials: app.credentials, defaultStorage: .thisDevice)
|
|
editor.label = "YubiKey"
|
|
editor.colorTag = .blue
|
|
editor.passphrase = "unused"
|
|
editor.confirmation = "mismatch"
|
|
#expect(!editor.canSave)
|
|
editor.usesWebAuthn = true
|
|
#expect(editor.canSave)
|
|
#expect(editor.verification == .preferred)
|
|
let credential = SecurityKeyCredential(
|
|
credentialID: Data([1, 2, 3]), publicKeyX963: P256.Signing.PrivateKey().publicKey.x963Representation)
|
|
try await editor.save { name, verification in
|
|
#expect(name == "YubiKey")
|
|
#expect(verification == .preferred)
|
|
return credential
|
|
}
|
|
let key = try #require(app.credentials.keys.first { $0.label == "YubiKey" })
|
|
#expect(key.algorithm == .securityKey && key.storage == .external)
|
|
#expect(key.colorTag == .blue && !key.hasPassphrase)
|
|
#expect(key.securityKey == credential)
|
|
}
|
|
|
|
@Test func webAuthnImportReadsReferencesAndAppliesSelectedVerification() async throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
defer { app.outbox.stop() }
|
|
let editor = MobileKeyEditorModel(mode: .importKey, credentials: app.credentials, defaultStorage: .thisDevice)
|
|
editor.usesWebAuthn = true
|
|
#expect(!editor.canSave)
|
|
var credential = SecurityKeyCredential(
|
|
credentialID: Data([4, 5, 6]), publicKeyX963: P256.Signing.PrivateKey().publicKey.x963Representation,
|
|
userVerification: .required)
|
|
let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
|
defer { try? FileManager.default.removeItem(at: url) }
|
|
try WebAuthnSSH.exportReference(credential).write(to: url, atomically: true, encoding: .utf8)
|
|
try await editor.loadFile(url)
|
|
#expect(editor.verification == .required && editor.canSave)
|
|
editor.verification = .discouraged
|
|
editor.label = "Imported YubiKey"
|
|
try await editor.save()
|
|
let key = try #require(app.credentials.keys.first { $0.label == "Imported YubiKey" })
|
|
credential.userVerification = .discouraged
|
|
#expect(key.securityKey == credential)
|
|
}
|
|
|
|
@Test func invalidWebAuthnImportDoesNotSaveAKey() async throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
defer { app.outbox.stop() }
|
|
let editor = MobileKeyEditorModel(mode: .importKey, credentials: app.credentials)
|
|
editor.usesWebAuthn = true
|
|
editor.securityKeyReference = "invalid"
|
|
let count = app.credentials.keys.count
|
|
await #expect(throws: (any Error).self) { try await editor.save() }
|
|
#expect(app.credentials.keys.count == count)
|
|
}
|
|
|
|
@Test func appDefaultsNewCredentialsToThisDeviceWhenVaultSyncIsOff() throws {
|
|
let domain = "MobileVaultCredentialDefaults.\(UUID())"
|
|
let defaults = try #require(UserDefaults(suiteName: domain))
|
|
defer { defaults.removePersistentDomain(forName: domain) }
|
|
defaults.set(false, forKey: SettingsKey.iCloudSyncEnabled)
|
|
let app = try MobileAppModel(inMemory: true, defaults: defaults)
|
|
defer { app.outbox.stop() }
|
|
|
|
#expect(app.newVaultKeyStorage == .thisDevice)
|
|
#expect(app.newVaultPasswordStorage == .thisDevice)
|
|
let generator = MobileKeyEditorModel(
|
|
mode: .generate,
|
|
credentials: app.credentials,
|
|
defaultStorage: app.newVaultKeyStorage)
|
|
#expect(generator.storage == .thisDevice)
|
|
|
|
generator.storage = .iCloud
|
|
#expect(generator.storage == .iCloud)
|
|
}
|
|
|
|
@Test func duplicatingAHostPersistsAnIndependentCopyWithTheSameConfiguration() async throws {
|
|
let group = HostGroup(name: "Production")
|
|
let jump = TermindDomain.Host(name: "Jump", host: "jump.example.invalid")
|
|
let host = TermindDomain.Host(
|
|
name: "Remote", host: "remote.example.invalid", port: 2222, identityID: UUID(),
|
|
createdAt: Date(timeIntervalSince1970: 100),
|
|
jumpHostID: jump.id,
|
|
proxy: HostProxy(kind: .socks5, hostname: "proxy.example.invalid", port: 1080),
|
|
sftpChannelInitialPath: "/srv/app", groupID: group.id, tags: ["production", "api"],
|
|
colorTag: .teal, detectedDistribution: DetectedDistribution(id: "debian"),
|
|
shellIntegrationMode: .enabled, portForwardingMaxConnections: 12)
|
|
var dependencies = MobileAppDependencies.testing()
|
|
let container = try TermindPersistence.makeContainer(inMemory: true)
|
|
_ = try HostPersistence(context: container.mainContext).save(.init(hosts: [jump, host], groups: [group]))
|
|
dependencies.container = container
|
|
let app = try MobileAppModel(dependencies: dependencies)
|
|
let original = try #require(app.library.host(host.id))
|
|
|
|
await app.duplicateHost(original)
|
|
|
|
#expect(app.library.hosts.count == 3)
|
|
#expect(app.library.host(original.id) == original)
|
|
let copy = try #require(app.library.hosts.first { $0.id != jump.id && $0.id != original.id })
|
|
#expect(copy.id != original.id)
|
|
#expect(copy.name == "Remote copy")
|
|
var expected = original
|
|
expected.id = copy.id
|
|
expected.name = copy.name
|
|
expected.sortIndex = copy.sortIndex
|
|
#expect(copy.createdAt != nil)
|
|
#expect(copy.createdAt != original.createdAt)
|
|
expected.createdAt = copy.createdAt
|
|
#expect(copy == expected)
|
|
app.outbox.stop()
|
|
}
|
|
|
|
@Test func deletingAGroupTreeCanKeepOrRemoveItsHosts() throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
defer { app.outbox.stop() }
|
|
|
|
let keptRoot = HostGroup(name: "Keep")
|
|
let keptChild = HostGroup(name: "Child", parentID: keptRoot.id)
|
|
let keptHost = TermindDomain.Host(
|
|
name: "Kept", host: "kept.example.invalid", groupID: keptChild.id)
|
|
app.library.upsert(keptRoot)
|
|
app.library.upsert(keptChild)
|
|
app.library.upsert(keptHost)
|
|
|
|
app.deleteHostGroup(keptRoot.id, keepHosts: true)
|
|
|
|
#expect(app.library.groups.isEmpty)
|
|
#expect(app.library.host(keptHost.id)?.groupID == nil)
|
|
|
|
let removedRoot = HostGroup(name: "Remove")
|
|
let removedChild = HostGroup(name: "Child", parentID: removedRoot.id)
|
|
let removedHost = TermindDomain.Host(
|
|
name: "Removed", host: "removed.example.invalid", groupID: removedChild.id)
|
|
app.library.upsert(removedRoot)
|
|
app.library.upsert(removedChild)
|
|
app.library.upsert(removedHost)
|
|
|
|
app.deleteHostGroup(removedRoot.id, keepHosts: false)
|
|
|
|
#expect(app.library.groups.isEmpty)
|
|
#expect(app.library.host(removedHost.id) == nil)
|
|
let stored = try HostPersistence(context: ModelContext(app.container)).load()
|
|
#expect(stored.groups.isEmpty)
|
|
#expect(stored.hosts.map(\.id) == [keptHost.id])
|
|
#expect(stored.hosts.first?.groupID == nil)
|
|
}
|
|
|
|
@Test func editingAHostPreservesTheMacForwardingPoolLimit() throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
var host = TermindDomain.Host(
|
|
name: "Remote", host: "example.invalid", portForwardingMaxConnections: 12)
|
|
app.library.upsert(host)
|
|
host.name = "Renamed"
|
|
app.library.upsert(host)
|
|
#expect(app.library.host(host.id)?.portForwardingMaxConnections == 12)
|
|
app.outbox.stop()
|
|
}
|
|
|
|
@Test func blankKeyNamesUseTheCurrentAlgorithmForGenerationImportAndReplacement() async throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
let generator = MobileKeyEditorModel(mode: .generate, credentials: app.credentials)
|
|
generator.algorithm = .ecdsa
|
|
generator.label = " \n"
|
|
#expect(generator.defaultName == "ECDSA")
|
|
#expect(generator.canSave)
|
|
try await generator.save()
|
|
let generated = try #require(app.credentials.keys.first)
|
|
#expect(generated.label == "ECDSA")
|
|
#expect(!generated.publicKey.isEmpty && !generated.fingerprint.isEmpty)
|
|
|
|
let replacement = try SSHKeyMaterial.generate(.ed25519, comment: "fixture", passphrase: nil)
|
|
let importer = MobileKeyEditorModel(mode: .importKey, credentials: app.credentials)
|
|
#expect(!importer.canSave)
|
|
#expect(importer.defaultName == "")
|
|
importer.privateKey = replacement.privateKey
|
|
await importer.refreshPublicMaterial()
|
|
#expect(importer.defaultName == "Ed25519")
|
|
#expect(importer.canSave)
|
|
try await importer.save()
|
|
let imported = try #require(app.credentials.keys.first { $0.id != generated.id })
|
|
#expect(imported.label == "Ed25519")
|
|
#expect(imported.publicKey.isEmpty && imported.fingerprint.isEmpty)
|
|
|
|
let editor = MobileKeyEditorModel(mode: .edit, credentials: app.credentials, key: generated)
|
|
await editor.loadPrivateKey()
|
|
editor.label = ""
|
|
editor.privateKey = replacement.privateKey
|
|
#expect(editor.defaultName == "")
|
|
await editor.refreshPublicMaterial()
|
|
#expect(editor.defaultName == "Ed25519")
|
|
#expect(editor.canSave)
|
|
try await editor.save()
|
|
let edited = try #require(app.credentials.key(generated.id))
|
|
#expect(edited.label == "Ed25519")
|
|
#expect(edited.algorithm == .ed25519)
|
|
#expect(edited.publicKey.isEmpty && edited.fingerprint.isEmpty)
|
|
let reopened = MobileKeyEditorModel(mode: .edit, credentials: app.credentials, key: edited)
|
|
#expect(reopened.label == "Ed25519")
|
|
await reopened.loadPrivateKey()
|
|
reopened.privateKey = "invalid replacement"
|
|
#expect(reopened.defaultName == "")
|
|
await reopened.refreshPublicMaterial()
|
|
#expect(reopened.defaultName == "")
|
|
reopened.privateKey = ""
|
|
#expect(reopened.defaultName == "")
|
|
#expect(!reopened.canSave)
|
|
}
|
|
|
|
@Test func enclaveMetadataCanSaveABlankNameWithoutGeneratingAKey() async throws {
|
|
let original = SSHKeyRef(label: "Custom", algorithm: .ecdsa256, storage: .secureEnclave)
|
|
let library = HostLibraryModel(keys: [original])
|
|
let credentials = CredentialLibraryModel(library: library)
|
|
let editor = MobileKeyEditorModel(mode: .edit, credentials: credentials, key: original)
|
|
editor.label = " \t"
|
|
#expect(editor.defaultName == "Secure Enclave")
|
|
#expect(editor.canSave)
|
|
try await editor.save()
|
|
#expect(credentials.key(original.id)?.label == "Secure Enclave")
|
|
#expect(credentials.key(original.id)?.publicKey == original.publicKey)
|
|
}
|
|
|
|
@Test func hostProxyEditorAppliesDefaultsPreservesCustomPortsAndValidatesAuthentication() {
|
|
var host = TermindDomain.Host(name: "Remote", host: "example.invalid")
|
|
let library = HostLibraryModel(hosts: [host])
|
|
var identityDraft = HostCustomIdentityDraft(host: host, library: library)
|
|
#expect(identityDraft.isProxyValid(for: host))
|
|
|
|
host.proxy = HostEditorBindings.selectProxy(.http, current: nil)
|
|
#expect(host.proxy?.port == 8080)
|
|
#expect(!identityDraft.isProxyValid(for: host))
|
|
|
|
host.proxy?.hostname = "proxy.example"
|
|
#expect(identityDraft.isProxyValid(for: host))
|
|
host.proxy?.port = 0
|
|
#expect(!identityDraft.isProxyValid(for: host))
|
|
host.proxy?.port = 8080
|
|
host.proxy = HostEditorBindings.selectProxy(.socks5, current: host.proxy)
|
|
#expect(host.proxy?.port == 1080)
|
|
#expect(host.proxy?.hostname == "proxy.example")
|
|
|
|
host.proxy?.port = 9000
|
|
host.proxy = HostEditorBindings.selectProxy(.http, current: host.proxy)
|
|
#expect(host.proxy?.port == 9000)
|
|
|
|
let identity = Identity(
|
|
name: "Proxy", username: "proxy-user", credentialRef: "termind.password.proxy")
|
|
identityDraft.selectProxyIdentity(identity, on: &host)
|
|
#expect(host.proxy?.identityID == identity.id)
|
|
#expect(host.proxy?.username == "proxy-user")
|
|
#expect(identityDraft.isProxyValid(for: host))
|
|
|
|
host.proxy?.username = ""
|
|
#expect(!identityDraft.isProxyValid(for: host))
|
|
identityDraft.clearProxyIdentity(on: &host)
|
|
#expect(host.proxy?.identityID == nil)
|
|
#expect(host.proxy?.username.isEmpty == true)
|
|
#expect(identityDraft.isProxyValid(for: host))
|
|
|
|
host.proxy = HostEditorBindings.selectProxy(nil, current: host.proxy)
|
|
#expect(host.proxy == nil)
|
|
}
|
|
|
|
@Test func mobileTagFieldAddsCreatesAndRemovesTagsLikeTheDesktopField() {
|
|
let suggestions = ["api", "production", "web"]
|
|
|
|
#expect(
|
|
TagFieldPolicy.rows(tags: ["production"], suggestions: suggestions, query: "PI") == [
|
|
.existing("api"), .create("PI"),
|
|
])
|
|
#expect(
|
|
TagFieldPolicy.rows(tags: [], suggestions: suggestions, query: "API") == [
|
|
.existing("api")
|
|
])
|
|
#expect(
|
|
TagFieldPolicy.rows(tags: ["production"], suggestions: suggestions, query: "edge") == [
|
|
.create("edge")
|
|
])
|
|
#expect(TagFieldPolicy.submission(query: " API ", suggestions: suggestions) == "api")
|
|
#expect(TagFieldPolicy.adding(" api ", to: ["production"]) == ["production", "api"])
|
|
#expect(TagFieldPolicy.adding(" Production ", to: ["production"]) == ["production"])
|
|
#expect(TagFieldPolicy.adding(" ", to: ["production"]) == ["production"])
|
|
#expect(TagFieldPolicy.removing("production", from: ["production", "api"]) == ["api"])
|
|
}
|
|
|
|
@Test func aTagDisappearsAfterItsLastHostStopsUsingIt() throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
defer { app.outbox.stop() }
|
|
var first = TermindDomain.Host(name: "First", host: "first.example.invalid", tags: ["production"])
|
|
var second = TermindDomain.Host(name: "Second", host: "second.example.invalid", tags: ["production"])
|
|
app.library.upsert(first)
|
|
app.library.upsert(second)
|
|
#expect(app.library.allTags == ["production"])
|
|
|
|
first.tags = []
|
|
app.library.upsert(first)
|
|
#expect(app.library.allTags == ["production"])
|
|
|
|
second.tags = []
|
|
app.library.upsert(second)
|
|
#expect(app.library.allTags.isEmpty)
|
|
}
|
|
|
|
@Test func identityCreationConfigurationsMatchHostAndProxyRequirements() {
|
|
let identity = Identity(name: "Deploy", username: "deploy", keyID: UUID())
|
|
|
|
#expect(MobileIdentityEditorConfiguration.standard.showsKey)
|
|
#expect(
|
|
MobileIdentityEditorConfiguration.standard.canSave(
|
|
identity: identity, password: "", hasConfiguredPassword: false))
|
|
#expect(!MobileIdentityEditorConfiguration.proxy.showsKey)
|
|
#expect(
|
|
!MobileIdentityEditorConfiguration.proxy.canSave(
|
|
identity: identity, password: "", hasConfiguredPassword: false))
|
|
#expect(
|
|
MobileIdentityEditorConfiguration.proxy.canSave(
|
|
identity: identity, password: "secret", hasConfiguredPassword: false))
|
|
#expect(
|
|
MobileIdentityEditorConfiguration.proxy.canSave(
|
|
identity: identity, password: "", hasConfiguredPassword: true))
|
|
}
|
|
|
|
@Test func identityEditorDistinguishesNewAndSavedEntries() throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
let identity = Identity(name: "Deploy", username: "deploy")
|
|
|
|
#expect(MobileIdentityEditor(app: app, identity: identity).isNew)
|
|
#expect(
|
|
MobileIdentityEditor(app: app, identity: identity).title
|
|
== String(localized: AppStrings.Keychain.newIdentity))
|
|
app.library.upsert(identity)
|
|
#expect(!MobileIdentityEditor(app: app, identity: identity).isNew)
|
|
#expect(
|
|
MobileIdentityEditor(app: app, identity: identity).title
|
|
== String(localized: AppStrings.Keychain.editIdentity))
|
|
app.outbox.stop()
|
|
}
|
|
|
|
@Test func newlyCreatedIdentitiesAreAppliedOnlyAfterTheEditorSaves() {
|
|
let originalIdentity = Identity(name: "Original", username: "old")
|
|
let createdIdentity = Identity(name: "Created", username: "new")
|
|
var host = TermindDomain.Host(
|
|
name: "Remote", host: "example.invalid", identityID: originalIdentity.id,
|
|
proxy: HostProxy(
|
|
kind: .http, hostname: "proxy.example.invalid", port: 8080,
|
|
username: originalIdentity.username, identityID: originalIdentity.id))
|
|
|
|
#expect(host.identityID == originalIdentity.id)
|
|
#expect(host.proxy?.identityID == originalIdentity.id)
|
|
|
|
var identityDraft = HostCustomIdentityDraft()
|
|
identityDraft.selectSSHIdentity(createdIdentity, on: &host)
|
|
#expect(host.identityID == createdIdentity.id)
|
|
identityDraft.selectProxyIdentity(createdIdentity, on: &host)
|
|
#expect(host.proxy?.identityID == createdIdentity.id)
|
|
#expect(host.proxy?.username == createdIdentity.username)
|
|
}
|
|
|
|
@Test func proxyIdentityChoicesRequireAnAvailablePasswordOnThisDevice() throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
let available = Identity(name: "Available", username: "proxy")
|
|
let missing = Identity(
|
|
name: "Missing", username: "proxy", credentialRef: "termind.password.missing")
|
|
let keyOnly = Identity(name: "Key only", username: "proxy", keyID: UUID())
|
|
|
|
try app.library.upsert(available, passwordUpdate: .replace("secret"))
|
|
app.library.upsert(missing)
|
|
app.library.upsert(keyOnly)
|
|
|
|
let storedAvailable = try #require(app.library.identity(available.id))
|
|
#expect(app.library.isProxyEligibleOnThisDevice(storedAvailable))
|
|
#expect(!app.library.isProxyEligibleOnThisDevice(missing))
|
|
#expect(!app.library.isProxyEligibleOnThisDevice(keyOnly))
|
|
}
|
|
|
|
@Test func savingAndForegroundingNeverStartForwardings() throws {
|
|
let suite = "MobileManualForwardingTests.\(UUID().uuidString)"
|
|
let defaults = try #require(UserDefaults(suiteName: suite))
|
|
defer { defaults.removePersistentDomain(forName: suite) }
|
|
let host = TermindDomain.Host(name: "Remote", host: "example.invalid")
|
|
let rule = PortForwardingRule(hostID: host.id)
|
|
defaults.set([rule.id.uuidString], forKey: "portForwarding.enabledRuleIDs")
|
|
let app = try MobileAppModel(inMemory: true, defaults: defaults)
|
|
app.library.upsert(host)
|
|
try app.forwardingLibrary.save(rule)
|
|
let sceneID = UUID()
|
|
app.sceneChanged(sceneID, phase: .active)
|
|
#expect(app.forwardings.state(for: rule.id) == .stopped)
|
|
#expect(app.forwardings.connection(for: rule.id) == nil)
|
|
app.forwardings.stopAll()
|
|
app.sceneChanged(sceneID, phase: .background)
|
|
app.sceneChanged(sceneID, phase: .active)
|
|
#expect(app.forwardings.state(for: rule.id) == .stopped)
|
|
app.removeScene(sceneID)
|
|
}
|
|
|
|
@Test func savedPassphraseDefaultsAndEditingPreserveOrRemoveTheSavedValue() async throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
let generator = MobileKeyEditorModel(mode: .generate, credentials: app.credentials)
|
|
#expect(generator.savePassphrase)
|
|
generator.passphrase = "test-only-passphrase"
|
|
generator.confirmation = generator.passphrase
|
|
try await generator.save()
|
|
let original = try #require(app.credentials.keys.first)
|
|
let editor = MobileKeyEditorModel(mode: .edit, credentials: app.credentials, key: original)
|
|
await editor.loadPrivateKey()
|
|
#expect(editor.savePassphrase && !editor.isDirty)
|
|
#expect(editor.passphrase == "test-only-passphrase")
|
|
editor.label = "Renamed"
|
|
try await editor.save()
|
|
guard case .software(let saved)? = try app.credentials.keyCredential(for: original) else {
|
|
Issue.record("Expected saved key")
|
|
return
|
|
}
|
|
#expect(saved.passphrase == "test-only-passphrase")
|
|
let updated = try #require(app.credentials.key(original.id))
|
|
let cancelled = MobileKeyEditorModel(mode: .edit, credentials: app.credentials, key: updated)
|
|
await cancelled.loadPrivateKey()
|
|
cancelled.savePassphrase = false
|
|
cancelled.clearSensitiveDrafts()
|
|
#expect(app.credentials.hasSavedPassphrase(for: updated))
|
|
let remover = MobileKeyEditorModel(mode: .edit, credentials: app.credentials, key: updated)
|
|
await remover.loadPrivateKey()
|
|
remover.savePassphrase = false
|
|
#expect(remover.isDirty)
|
|
#expect(remover.passphrase == "test-only-passphrase")
|
|
#expect(app.credentials.hasSavedPassphrase(for: updated))
|
|
try await remover.save()
|
|
guard case .software(let removed)? = try app.credentials.keyCredential(for: updated) else {
|
|
Issue.record("Expected saved key")
|
|
return
|
|
}
|
|
#expect(removed.passphrase == nil)
|
|
#expect(removed.privateKey == saved.privateKey)
|
|
#expect(app.credentials.key(original.id)?.hasPassphrase == true)
|
|
#expect(app.credentials.key(original.id)?.publicKey == original.publicKey)
|
|
#expect(!app.credentials.hasSavedPassphrase(for: updated))
|
|
#expect(app.library.requiresPassphraseEntry(for: updated))
|
|
let reopened = MobileKeyEditorModel(mode: .edit, credentials: app.credentials, key: updated)
|
|
await reopened.loadPrivateKey()
|
|
#expect(!reopened.savePassphrase && reopened.passphrase.isEmpty && !reopened.isDirty)
|
|
reopened.passphrase = "test-only-passphrase"
|
|
#expect(!reopened.savePassphrase)
|
|
reopened.savePassphrase = true
|
|
try await reopened.save()
|
|
#expect(app.credentials.hasSavedPassphrase(for: updated))
|
|
let importer = MobileKeyEditorModel(mode: .importKey, credentials: app.credentials)
|
|
#expect(importer.savePassphrase)
|
|
importer.privateKey = saved.privateKey
|
|
importer.passphrase = "test-only-passphrase"
|
|
importer.savePassphrase = false
|
|
try await importer.save()
|
|
let imported = try #require(app.credentials.keys.first { $0.id != original.id })
|
|
guard case .software(let importedSecret)? = try app.credentials.keyCredential(for: imported) else {
|
|
Issue.record("Expected imported key")
|
|
return
|
|
}
|
|
#expect(importedSecret.passphrase == nil)
|
|
}
|
|
|
|
@Test func disablingPassphraseSavingBeforeTypingKeepsGeneratedAndImportedKeysEncrypted() async throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
let generator = MobileKeyEditorModel(mode: .generate, credentials: app.credentials)
|
|
generator.savePassphrase = false
|
|
for value in ["first", "", "test-only-passphrase"] {
|
|
generator.passphrase = value
|
|
#expect(!generator.savePassphrase)
|
|
}
|
|
generator.confirmation = generator.passphrase
|
|
try await generator.save()
|
|
let generated = try #require(app.credentials.keys.first)
|
|
guard case .software(let secret)? = try app.credentials.keyCredential(for: generated) else {
|
|
Issue.record("Expected generated key")
|
|
return
|
|
}
|
|
#expect(secret.passphrase == nil && generated.hasPassphrase)
|
|
#expect(throws: (any Error).self) {
|
|
try SSHKeyMaterial.inspect(privateKey: secret.privateKey, passphrase: nil, comment: "fixture", keySize: nil)
|
|
}
|
|
let importer = MobileKeyEditorModel(mode: .importKey, credentials: app.credentials)
|
|
importer.savePassphrase = false
|
|
importer.privateKey = secret.privateKey
|
|
importer.passphrase = "test-only-passphrase"
|
|
#expect(!importer.savePassphrase)
|
|
try await importer.save()
|
|
let imported = try #require(app.credentials.keys.first { $0.id != generated.id })
|
|
guard case .software(let importedSecret)? = try app.credentials.keyCredential(for: imported) else {
|
|
Issue.record("Expected imported key")
|
|
return
|
|
}
|
|
#expect(importedSecret.passphrase == nil && imported.hasPassphrase)
|
|
#expect(imported.publicKey.isEmpty)
|
|
}
|
|
|
|
@Test(arguments: [MobileKeyEditorModel.Mode.generate, .importKey])
|
|
func keyEditorTracksItsConfiguredInitialStorage(mode: MobileKeyEditorModel.Mode) throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
let editor = MobileKeyEditorModel(mode: mode, credentials: app.credentials, defaultStorage: .thisDevice)
|
|
#expect(!editor.isDirty)
|
|
for _ in 0..<2 {
|
|
editor.storage = .iCloud
|
|
#expect(editor.isDirty)
|
|
editor.storage = .thisDevice
|
|
#expect(!editor.isDirty)
|
|
}
|
|
}
|
|
|
|
@Test func revertingPrivateKeyRestoresCleanStateWithoutReusingSecretsForReplacements() async throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
let generator = MobileKeyEditorModel(mode: .generate, credentials: app.credentials)
|
|
generator.passphrase = "original-passphrase"
|
|
generator.confirmation = generator.passphrase
|
|
try await generator.save()
|
|
let key = try #require(app.credentials.keys.first)
|
|
let editor = MobileKeyEditorModel(mode: .edit, credentials: app.credentials, key: key)
|
|
await editor.loadPrivateKey()
|
|
let originalPrivateKey = editor.privateKey
|
|
let replacement = try SSHKeyMaterial.generate(.ed25519, comment: "replacement", passphrase: nil)
|
|
#expect(!editor.isDirty)
|
|
for _ in 0..<2 {
|
|
editor.privateKey = replacement.privateKey
|
|
#expect(editor.publicKey.isEmpty && editor.passphrase.isEmpty && editor.isDirty)
|
|
editor.privateKey = originalPrivateKey
|
|
#expect(editor.publicKey == key.publicKey && editor.passphrase == "original-passphrase")
|
|
#expect(!editor.isDirty)
|
|
}
|
|
editor.label = "Renamed"
|
|
try await editor.save()
|
|
guard case .software(let secret)? = try app.credentials.keyCredential(for: key) else {
|
|
Issue.record("Expected original key")
|
|
return
|
|
}
|
|
#expect(secret.privateKey == originalPrivateKey && secret.passphrase == "original-passphrase")
|
|
}
|
|
|
|
@Test func replacingPrivateKeyDoesNotReuseItsSavedPassphrase() async throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
let generator = MobileKeyEditorModel(mode: .generate, credentials: app.credentials)
|
|
generator.passphrase = "old-passphrase"
|
|
generator.confirmation = generator.passphrase
|
|
try await generator.save()
|
|
let key = try #require(app.credentials.keys.first)
|
|
let editor = MobileKeyEditorModel(mode: .edit, credentials: app.credentials, key: key)
|
|
await editor.loadPrivateKey()
|
|
editor.privateKey = try SSHKeyMaterial.generate(.ed25519, comment: "replacement", passphrase: nil).privateKey
|
|
#expect(editor.passphrase.isEmpty)
|
|
try await editor.save()
|
|
guard case .software(let secret)? = try app.credentials.keyCredential(for: key) else {
|
|
Issue.record("Expected replacement key")
|
|
return
|
|
}
|
|
#expect(secret.passphrase == nil)
|
|
#expect(app.credentials.key(key.id)?.hasPassphrase == false)
|
|
}
|
|
|
|
@Test func encryptedKeyGenerationImportAndMetadataEditingKeepTheSecretOutOfRecords() async throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
let generator = MobileKeyEditorModel(mode: .generate, credentials: app.credentials)
|
|
#expect(!generator.isDirty)
|
|
generator.label = "Generated"
|
|
#expect(generator.isDirty)
|
|
#expect(generator.storage == .iCloud)
|
|
generator.passphrase = "test-only-passphrase"
|
|
generator.confirmation = "test-only-passphrase"
|
|
try await generator.save()
|
|
let key = try #require(app.credentials.keys.first)
|
|
#expect(key.hasPassphrase)
|
|
#expect(generator.privateKey.isEmpty && generator.passphrase.isEmpty)
|
|
let savedCredential = try app.credentials.keyCredential(for: key)
|
|
guard case .software(let secret) = try #require(savedCredential) else {
|
|
Issue.record("Expected a software key")
|
|
return
|
|
}
|
|
let record = String(decoding: try JSONEncoder().encode(key), as: UTF8.self)
|
|
#expect(!record.contains("PRIVATE KEY"))
|
|
#expect(!record.contains("test-only-passphrase"))
|
|
|
|
let invalid = MobileKeyEditorModel(mode: .importKey, credentials: app.credentials)
|
|
invalid.label = "Invalid"
|
|
invalid.privateKey = "not a private key"
|
|
invalid.passphrase = "wrong"
|
|
await #expect(throws: (any Error).self) { try await invalid.save() }
|
|
#expect(app.credentials.keys.count == 1)
|
|
|
|
let importer = MobileKeyEditorModel(mode: .importKey, credentials: app.credentials)
|
|
importer.label = "Imported"
|
|
importer.privateKey = secret.privateKey
|
|
importer.passphrase = "test-only-passphrase"
|
|
try await importer.save()
|
|
#expect(app.credentials.keys.count == 2)
|
|
let imported = try #require(app.credentials.keys.first { $0.id != key.id })
|
|
#expect(imported.publicKey.isEmpty && imported.fingerprint.isEmpty && imported.hasPassphrase)
|
|
|
|
let editor = MobileKeyEditorModel(mode: .edit, credentials: app.credentials, key: key)
|
|
await editor.loadPrivateKey()
|
|
#expect(!editor.isDirty)
|
|
#expect(editor.privateKey == secret.privateKey)
|
|
editor.label = "Renamed"
|
|
try await editor.save()
|
|
let edited = try #require(app.credentials.key(key.id))
|
|
#expect(edited.label == "Renamed")
|
|
#expect(edited.credentialRef == key.credentialRef)
|
|
#expect(try app.credentials.keyCredential(for: edited) == .software(secret))
|
|
}
|
|
|
|
@Test func editingSoftwareKeyReplacesPrivateMaterialAndDerivedMetadata() async throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
let originalMaterial = try SSHKeyMaterial.generate(.ed25519, comment: "original", passphrase: nil)
|
|
let original = SSHKeyRef(
|
|
label: "Deploy", algorithm: originalMaterial.algorithm, publicKey: originalMaterial.publicKey,
|
|
fingerprint: originalMaterial.fingerprint, credentialRef: "termind.key.\(UUID())", storage: .iCloud,
|
|
keySize: originalMaterial.keySize)
|
|
try app.credentials.upsert(
|
|
original, credential: .software(SSHPrivateKeySecret(privateKey: originalMaterial.privateKey)))
|
|
|
|
let editor = MobileKeyEditorModel(mode: .edit, credentials: app.credentials, key: original)
|
|
#expect(editor.isLoadingPrivateKey)
|
|
#expect(!editor.canSave)
|
|
await editor.loadPrivateKey()
|
|
#expect(editor.privateKeyLoadError == nil)
|
|
#expect(editor.privateKey == originalMaterial.privateKey)
|
|
#expect(!editor.isDirty)
|
|
#expect(editor.canSave)
|
|
|
|
let replacement = try SSHKeyMaterial.generate(.rsa(bits: 2048), comment: "replacement", passphrase: nil)
|
|
editor.privateKey = replacement.privateKey
|
|
#expect(editor.publicKey.isEmpty)
|
|
editor.publicKey = replacement.publicKey
|
|
await editor.refreshPublicMaterial()
|
|
#expect(editor.replacesPublicKey)
|
|
#expect(editor.fingerprint == replacement.fingerprint)
|
|
try await editor.save()
|
|
|
|
let updated = try #require(app.credentials.key(original.id))
|
|
#expect(updated.id == original.id)
|
|
#expect(updated.credentialRef == original.credentialRef)
|
|
#expect(updated.algorithm == .rsa)
|
|
#expect(updated.keySize == 2048)
|
|
#expect(updated.fingerprint == replacement.fingerprint)
|
|
#expect(updated.publicKey.hasPrefix("ssh-rsa "))
|
|
#expect(!updated.hasPassphrase)
|
|
#expect(
|
|
try app.credentials.keyCredential(for: updated)
|
|
== .software(SSHPrivateKeySecret(privateKey: replacement.privateKey))
|
|
)
|
|
#expect(editor.privateKey.isEmpty && editor.passphrase.isEmpty)
|
|
}
|
|
|
|
@Test func encryptedSoftwareKeyCanBeRenamedWithoutReenteringItsPassphrase() async throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
let material = try SSHKeyMaterial.generate(
|
|
.ed25519, comment: "protected", passphrase: "test-only-passphrase")
|
|
let original = SSHKeyRef(
|
|
label: "Protected", algorithm: material.algorithm, publicKey: material.publicKey,
|
|
fingerprint: material.fingerprint, credentialRef: "termind.key.\(UUID())", storage: .iCloud,
|
|
keySize: material.keySize, hasPassphrase: true)
|
|
let secret = SSHPrivateKeySecret(privateKey: material.privateKey)
|
|
try app.credentials.upsert(original, credential: .software(secret))
|
|
|
|
let editor = MobileKeyEditorModel(mode: .edit, credentials: app.credentials, key: original)
|
|
await editor.loadPrivateKey()
|
|
editor.label = "Renamed Protected"
|
|
try await editor.save()
|
|
|
|
let updated = try #require(app.credentials.key(original.id))
|
|
#expect(updated.label == "Renamed Protected")
|
|
#expect(updated.hasPassphrase)
|
|
#expect(updated.publicKey == original.publicKey)
|
|
#expect(updated.fingerprint == original.fingerprint)
|
|
#expect(try app.credentials.keyCredential(for: updated) == .software(secret))
|
|
}
|
|
|
|
@Test func missingPrivateKeyAllowsMetadataOnlyEditWithoutCreatingACredential() async throws {
|
|
let original = SSHKeyRef(
|
|
label: "Missing", publicKey: "ssh-ed25519 test", fingerprint: "SHA256:test",
|
|
credentialRef: "termind.key.\(UUID())", storage: .thisDevice)
|
|
let library = HostLibraryModel(keys: [original], secrets: InMemorySecretStore())
|
|
let credentials = CredentialLibraryModel(library: library)
|
|
let editor = MobileKeyEditorModel(mode: .edit, credentials: credentials, key: original)
|
|
|
|
await editor.loadPrivateKey()
|
|
#expect(editor.privateKeyLoadError != nil)
|
|
#expect(!editor.canEditPrivateKey)
|
|
#expect(editor.canSave)
|
|
|
|
editor.storage = .iCloud
|
|
#expect(!editor.canSave)
|
|
editor.storage = original.storage
|
|
editor.privateKey = "replacement"
|
|
#expect(!editor.canSave)
|
|
editor.privateKey = ""
|
|
editor.certificate = "not an SSH certificate"
|
|
#expect(editor.canSave)
|
|
await #expect(throws: (any Error).self) { try await editor.save() }
|
|
#expect(credentials.key(original.id) == original)
|
|
editor.certificate = ""
|
|
|
|
editor.label = "Renamed Missing"
|
|
editor.colorTag = .orange
|
|
try await editor.save()
|
|
let updated = try #require(credentials.key(original.id))
|
|
#expect(updated.label == "Renamed Missing")
|
|
#expect(updated.colorTag == .orange)
|
|
#expect(updated.credentialRef == original.credentialRef)
|
|
#expect(updated.publicKey == original.publicKey)
|
|
#expect(try credentials.keyCredential(for: updated) == nil)
|
|
}
|
|
|
|
@Test func missingIdentityPasswordLocksOnlyPasswordEditing() throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
let identity = Identity(
|
|
name: "Remote", username: "deploy", credentialRef: "termind.password.remote",
|
|
passwordStorage: .thisDevice)
|
|
app.library.upsert(identity)
|
|
|
|
let editor = MobileIdentityEditor(app: app, identity: identity)
|
|
#expect(!editor.canEditPassword)
|
|
#expect(
|
|
MobileIdentityEditorConfiguration.standard.canSave(
|
|
identity: identity, password: "", hasConfiguredPassword: true))
|
|
}
|
|
|
|
@Test func invalidPrivateKeyAndCertificateEditsPreserveTheOriginalCredential() async throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
let material = try SSHKeyMaterial.generate(.ed25519, comment: "original", passphrase: nil)
|
|
let original = SSHKeyRef(
|
|
label: "Deploy", algorithm: material.algorithm, publicKey: material.publicKey,
|
|
fingerprint: material.fingerprint, credentialRef: "termind.key.\(UUID())", storage: .iCloud,
|
|
keySize: material.keySize)
|
|
let secret = SSHPrivateKeySecret(privateKey: material.privateKey)
|
|
try app.credentials.upsert(original, credential: .software(secret))
|
|
|
|
let invalidKeyEditor = MobileKeyEditorModel(mode: .edit, credentials: app.credentials, key: original)
|
|
await invalidKeyEditor.loadPrivateKey()
|
|
invalidKeyEditor.privateKey = "not a private key"
|
|
await #expect(throws: (any Error).self) { try await invalidKeyEditor.save() }
|
|
#expect(app.credentials.key(original.id) == original)
|
|
#expect(try app.credentials.keyCredential(for: original) == .software(secret))
|
|
|
|
let invalidCertificateEditor = MobileKeyEditorModel(
|
|
mode: .edit, credentials: app.credentials, key: original)
|
|
await invalidCertificateEditor.loadPrivateKey()
|
|
invalidCertificateEditor.certificate = "not an SSH certificate"
|
|
await #expect(throws: (any Error).self) { try await invalidCertificateEditor.save() }
|
|
#expect(app.credentials.key(original.id) == original)
|
|
#expect(try app.credentials.keyCredential(for: original) == .software(secret))
|
|
}
|
|
|
|
@Test func editingICloudSoftwareKeyCannotMoveItToThisDevice() async throws {
|
|
let secrets = MobileStorageAwareSecretStore()
|
|
let material = try SSHKeyMaterial.generate(.ed25519, comment: "move", passphrase: nil)
|
|
let original = SSHKeyRef(
|
|
label: "Move", algorithm: material.algorithm, publicKey: material.publicKey,
|
|
fingerprint: material.fingerprint, credentialRef: "termind.key.\(UUID())", storage: .iCloud,
|
|
keySize: material.keySize)
|
|
let secret = StoredSSHKeyCredential.software(SSHPrivateKeySecret(privateKey: material.privateKey))
|
|
let library = HostLibraryModel(keys: [original], secrets: secrets)
|
|
let credentials = CredentialLibraryModel(library: library)
|
|
try credentials.upsert(original, credential: secret)
|
|
|
|
let editor = MobileKeyEditorModel(mode: .edit, credentials: credentials, key: original)
|
|
await editor.loadPrivateKey()
|
|
#expect(!editor.canSelectThisDevice)
|
|
editor.storage = .thisDevice
|
|
#expect(!editor.canSave)
|
|
await #expect(throws: (any Error).self) { try await editor.save() }
|
|
|
|
let updated = try #require(credentials.key(original.id))
|
|
#expect(updated.storage == .iCloud)
|
|
#expect(try secrets.keyCredential(for: original.credentialRef, storage: .thisDevice) == nil)
|
|
#expect(try secrets.keyCredential(for: original.credentialRef, storage: .iCloud) == secret)
|
|
}
|
|
|
|
@Test func forwardingEditsValidateAndStopOnlyTheChangedRule() throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
let host = TermindDomain.Host(name: "Remote", host: "example.invalid")
|
|
app.library.upsert(host)
|
|
var rule = PortForwardingRule(hostID: host.id)
|
|
try app.forwardingLibrary.save(rule)
|
|
#expect(app.forwardings.state(for: rule.id) == .stopped)
|
|
var stopped: [UUID] = []
|
|
app.library.willDeletePortForwardings = { stopped.append(contentsOf: $0) }
|
|
rule.bindPort = 9000
|
|
try app.forwardingLibrary.save(rule)
|
|
#expect(stopped == [rule.id])
|
|
var invalid = rule
|
|
invalid.bindPort = 0
|
|
#expect(throws: (any Error).self) { try app.forwardingLibrary.save(invalid) }
|
|
#expect(app.forwardingLibrary.portForwarding(rule.id)?.bindPort == 9000)
|
|
try app.forwardingLibrary.delete(rule.id)
|
|
#expect(app.forwardingLibrary.rules.isEmpty)
|
|
}
|
|
|
|
@Test func deletingAHostCompletesDurableCleanup() async throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
defer { app.outbox.stop() }
|
|
var host = TermindDomain.Host(
|
|
name: "Remote", host: "example.invalid",
|
|
proxy: HostProxy(kind: .http, hostname: "proxy.invalid", port: 8080))
|
|
app.library.upsert(host)
|
|
let dependent = TermindDomain.Host(name: "Dependent", host: "dependent.invalid", jumpHostID: host.id)
|
|
app.library.upsert(dependent)
|
|
let reusable = Identity(name: "Reusable", username: "shared")
|
|
app.library.upsert(reusable)
|
|
let ssh = try #require(
|
|
try app.library.saveCustomIdentity(
|
|
ownerHostID: host.id, purpose: .ssh, displayName: host.name,
|
|
username: "deploy", password: "ssh-secret", keyID: nil))
|
|
let proxy = try #require(
|
|
try app.library.saveCustomIdentity(
|
|
ownerHostID: host.id, purpose: .proxy, displayName: "Remote Proxy",
|
|
username: "proxy", password: "proxy-secret", keyID: nil))
|
|
let retained = try #require(
|
|
try app.library.saveCustomIdentity(
|
|
ownerHostID: dependent.id, purpose: .ssh, displayName: dependent.name,
|
|
username: "dependent", password: "retained-secret", keyID: nil))
|
|
host.identityID = ssh.id
|
|
host.proxy?.identityID = proxy.id
|
|
host.proxy?.username = proxy.username
|
|
#expect(app.library.saveHost(host))
|
|
|
|
app.deleteHost(host.id)
|
|
#expect(app.library.host(host.id) == nil)
|
|
#expect(app.library.identity(ssh.id) == nil)
|
|
#expect(app.library.identity(proxy.id) == nil)
|
|
#expect(app.library.identity(reusable.id) == reusable)
|
|
#expect(app.library.identity(retained.id)?.ownerHostID == dependent.id)
|
|
var finished = false
|
|
for _ in 0..<100 {
|
|
let context = ModelContext(app.container)
|
|
let jobs = try context.fetch(FetchDescriptor<PersistentOutboxJob>())
|
|
if jobs.count == 5 && jobs.allSatisfy({ $0.status == OutboxJobStatus.succeeded.rawValue }) {
|
|
finished = true
|
|
break
|
|
}
|
|
try await Task.sleep(for: .milliseconds(20))
|
|
}
|
|
#expect(finished)
|
|
let saved = try HostPersistence(context: ModelContext(app.container)).load()
|
|
#expect(!saved.hosts.contains(where: { $0.id == host.id }))
|
|
#expect(saved.hosts.first(where: { $0.id == dependent.id })?.jumpHostID == nil)
|
|
#expect(Set(saved.identities.map(\.id)) == [reusable.id, retained.id])
|
|
}
|
|
}
|
|
|
|
private final class MobileStorageAwareSecretStore: SecretStoring, @unchecked Sendable {
|
|
private let lock = NSLock()
|
|
private var strings: [String: String] = [:]
|
|
private var keys: [String: StoredSSHKeyCredential] = [:]
|
|
|
|
func string(for account: String, storage: PasswordStorage) throws -> String? {
|
|
lock.withLock { strings["\(storage.rawValue):\(account)"] }
|
|
}
|
|
|
|
func setString(_ value: String?, for account: String, storage: PasswordStorage) throws {
|
|
lock.withLock { strings["\(storage.rawValue):\(account)"] = value }
|
|
}
|
|
|
|
func keyCredential(for account: String, storage: KeyStorage) throws -> StoredSSHKeyCredential? {
|
|
lock.withLock { keys["\(storage.rawValue):\(account)"] }
|
|
}
|
|
|
|
func setKeyCredential(
|
|
_ value: StoredSSHKeyCredential?, for account: String, storage: KeyStorage
|
|
) throws {
|
|
lock.withLock { keys["\(storage.rawValue):\(account)"] = value }
|
|
}
|
|
|
|
func removePassword(_ account: String, storage: PasswordStorage) throws {
|
|
lock.withLock { strings["\(storage.rawValue):\(account)"] = nil }
|
|
}
|
|
|
|
func removeKeyCredential(_ account: String, storage: KeyStorage) throws {
|
|
lock.withLock { keys["\(storage.rawValue):\(account)"] = nil }
|
|
}
|
|
}
|