Files
Termind/TFMTests/IdentityCredentialTests.swift
T

405 lines
17 KiB
Swift

import Foundation
import TermindApplication
import TermindDomain
import TermindInfrastructure
import TermindPersistence
import TermindPresentation
import TermindRemoteInfrastructure
import Testing
@testable import Termind
@Suite(.serialized)
@MainActor
struct IdentityCredentialTests {
@Test
func hostCustomIdentityIsHiddenMigratesOneWayAndDeletesWithHost() throws {
let keychain = InMemorySecretStore()
let suite = "IdentityCredentialTests.\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suite))
defer { defaults.removePersistentDomain(forName: suite) }
defaults.set(false, forKey: SettingsKey.iCloudSyncEnabled)
defaults.set(true, forKey: SettingsKey.vaultSyncEnabled)
let host = Host(name: "Server", host: "example.com")
let otherHost = Host(name: "Other", host: "other.example.com")
let reusable = Identity(name: "Reusable", username: "shared")
let store = HostLibraryModel(
hosts: [host, otherHost], identities: [reusable], secrets: keychain,
localDefaults: defaults)
let result = try store.saveCustomIdentity(
ownerHostID: host.id, purpose: .ssh, displayName: host.name,
username: "deploy", password: "secret", keyID: nil)
let saved = try #require(result)
let proxy = try #require(
try store.saveCustomIdentity(
ownerHostID: host.id, purpose: .proxy, displayName: "Server Proxy",
username: "proxy", password: "proxy-secret", keyID: nil))
let retained = try #require(
try store.saveCustomIdentity(
ownerHostID: otherHost.id, purpose: .ssh, displayName: otherHost.name,
username: "other", password: "other-secret", keyID: nil))
#expect(saved.isHostCustom)
#expect(saved.passwordStorage == .thisDevice)
#expect(proxy.isHostCustom)
#expect(store.reusableIdentities == [reusable])
#expect(store.searchIdentities("") == [reusable])
#expect(try keychain.string(for: saved.credentialRef, storage: .thisDevice) == "secret")
#expect(try keychain.string(for: proxy.credentialRef, storage: .thisDevice) == "proxy-secret")
defaults.set(true, forKey: SettingsKey.iCloudSyncEnabled)
try store.migrateCustomPasswordsToICloud()
let migrated = try #require(store.identity(saved.id))
#expect(migrated.passwordStorage == .iCloud)
#expect(try keychain.string(for: saved.credentialRef, storage: .thisDevice) == nil)
store.projectHostDeletion(host.id)
#expect(store.identity(saved.id) == nil)
#expect(store.identity(proxy.id) == nil)
#expect(store.identity(reusable.id) == reusable)
let retainedAfterDeletion = try #require(store.identity(retained.id))
#expect(retainedAfterDeletion.ownerHostID == otherHost.id)
#expect(try keychain.string(for: saved.credentialRef, storage: .iCloud) == nil)
#expect(try keychain.string(for: proxy.credentialRef, storage: .iCloud) == nil)
}
@Test
func desktopHostDeletionRemovesBothCustomIdentityPurposesThroughTheAppEntryPoint() throws {
let keychain = InMemorySecretStore()
var host = Host(
name: "Server", host: "server.invalid",
proxy: HostProxy(kind: .http, hostname: "proxy.invalid", port: 8080))
let otherHost = Host(name: "Other", host: "other.invalid")
let reusable = Identity(name: "Reusable", username: "shared")
let store = HostLibraryModel(
hosts: [host, otherHost], identities: [reusable], secrets: keychain)
let app = AppModel(hosts: store)
defer { app.shutdownForTermination() }
let ssh = try #require(
try store.saveCustomIdentity(
ownerHostID: host.id, purpose: .ssh, displayName: host.name,
username: "deploy", password: "ssh-secret", keyID: nil))
let proxy = try #require(
try store.saveCustomIdentity(
ownerHostID: host.id, purpose: .proxy, displayName: "Server Proxy",
username: "proxy", password: "proxy-secret", keyID: nil))
let retained = try #require(
try store.saveCustomIdentity(
ownerHostID: otherHost.id, purpose: .ssh, displayName: otherHost.name,
username: "other", password: "other-secret", keyID: nil))
host.identityID = ssh.id
host.proxy?.identityID = proxy.id
host.proxy?.username = proxy.username
#expect(store.saveHost(host))
#expect(app.requestHostDeletion(host.id))
#expect(store.identity(ssh.id) == nil)
#expect(store.identity(proxy.id) == nil)
#expect(store.identity(reusable.id) == reusable)
#expect(store.identity(retained.id)?.ownerHostID == otherHost.id)
#expect(try keychain.string(for: ssh.credentialRef, storage: ssh.passwordStorage) == nil)
#expect(try keychain.string(for: proxy.credentialRef, storage: proxy.passwordStorage) == nil)
#expect(
try keychain.string(for: retained.credentialRef, storage: retained.passwordStorage)
== "other-secret")
}
@Test
func failedCustomPasswordRemovalReportsTheErrorWithoutRestoringTheDeletedHost() throws {
let host = Host(name: "Server", host: "server.invalid")
let identity = Identity(
name: "Server", username: "deploy", credentialRef: "owned-password",
passwordStorage: .thisDevice, ownerHostID: host.id, customPurpose: .ssh)
let secrets = FailingPasswordRemovalStore()
try secrets.base.setString(
"secret", for: identity.credentialRef, storage: identity.passwordStorage)
let store = HostLibraryModel(
hosts: [host], identities: [identity], secrets: secrets)
store.projectHostDeletion(host.id)
#expect(store.host(host.id) == nil)
#expect(store.identity(identity.id) == nil)
#expect(store.storageError != nil)
#expect(
try secrets.base.string(
for: identity.credentialRef, storage: identity.passwordStorage) == "secret")
}
@Test
func passwordUpdatesKeepReplaceAndRemoveTheKeychainItem() throws {
let keychain = InMemorySecretStore()
let reference = "termind.password.\(UUID().uuidString)"
let id = UUID()
let keyID = UUID()
let original = Identity(
id: id, name: "Deploy", username: "deploy",
credentialRef: reference, passwordStorage: .thisDevice, keyID: keyID
)
let store = HostLibraryModel(identities: [original], secrets: keychain)
defer { try? keychain.removePassword(reference, storage: .thisDevice) }
try keychain.setString("old", for: reference, storage: .thisDevice)
var renamed = original
renamed.name = "Production"
try store.upsert(renamed, passwordUpdate: .keep)
#expect(store.identity(id)?.credentialRef == reference)
#expect(try keychain.string(for: reference, storage: .thisDevice) == "old")
try store.upsert(renamed, passwordUpdate: .replace("new"))
#expect(store.identity(id)?.credentialRef == reference)
#expect(try keychain.string(for: reference, storage: .thisDevice) == "new")
try store.upsert(renamed, passwordUpdate: .remove)
#expect(store.identity(id)?.credentialRef == "")
#expect(store.identity(id)?.keyID == keyID)
#expect(try keychain.string(for: reference, storage: .thisDevice) == nil)
}
@Test
func addingAPasswordMintsAReference() throws {
let keychain = InMemorySecretStore()
let identity = Identity(name: "Deploy", username: "deploy")
let store = HostLibraryModel(secrets: keychain)
try store.upsert(identity, passwordUpdate: .replace("secret"))
let saved = try #require(store.identity(identity.id))
defer {
try? keychain.removePassword(
saved.credentialRef, storage: saved.passwordStorage
)
}
#expect(!saved.credentialRef.isEmpty)
#expect(
try keychain.string(
for: saved.credentialRef, storage: saved.passwordStorage
) == "secret"
)
}
@Test
func missingPasswordAllowsMetadataOnlyEditWithoutChangingItsReference() throws {
let reference = "termind.password.\(UUID().uuidString)"
let identity = Identity(
name: "Remote", username: "deploy", credentialRef: reference,
passwordStorage: .thisDevice)
let store = HostLibraryModel(identities: [identity], secrets: InMemorySecretStore())
var renamed = identity
renamed.name = "Renamed Remote"
renamed.colorTag = .orange
try store.upsert(renamed, passwordUpdate: .keep)
let updated = try #require(store.identity(identity.id))
#expect(updated.name == "Renamed Remote")
#expect(updated.colorTag == .orange)
#expect(updated.credentialRef == reference)
#expect(store.passwordAvailability(for: updated) == .missing)
}
@Test
func iCloudPasswordCannotMoveToThisDevice() throws {
let keychain = InMemorySecretStore()
let reference = "termind.password.\(UUID().uuidString)"
let original = Identity(
name: "Deploy", username: "deploy", credentialRef: reference,
passwordStorage: .iCloud
)
let store = HostLibraryModel(identities: [original], secrets: keychain)
try keychain.setString("secret", for: reference, storage: .iCloud)
var moved = original
moved.passwordStorage = .thisDevice
#expect(throws: (any Error).self) {
try store.upsert(moved, passwordUpdate: .keep)
}
#expect(!store.upsert(moved))
#expect(store.identity(original.id)?.passwordStorage == .iCloud)
#expect(try keychain.string(for: reference, storage: .iCloud) == "secret")
}
@Test
func iCloudKeyCannotMoveToThisDevice() throws {
let keychain = InMemorySecretStore()
let reference = "termind.key.\(UUID().uuidString)"
let original = SSHKeyRef(
label: "Deploy", publicKey: "ssh-ed25519 test", fingerprint: "SHA256:test",
credentialRef: reference, storage: .iCloud
)
let secret = StoredSSHKeyCredential.software(
SSHPrivateKeySecret(privateKey: "private")
)
let store = HostLibraryModel(keys: [original], secrets: keychain)
try keychain.setKeyCredential(secret, for: reference, storage: .iCloud)
var moved = original
moved.storage = .thisDevice
#expect(throws: (any Error).self) {
try store.upsert(moved)
}
#expect(store.key(original.id)?.storage == .iCloud)
#expect(try keychain.keyCredential(for: reference, storage: .iCloud) == secret)
}
@Test
func missingKeyDoesNotSuppressAnAvailablePassword() throws {
let keychain = InMemorySecretStore()
let passwordReference = "termind.password.\(UUID().uuidString)"
let missingKeyReference = "termind.key.\(UUID().uuidString)"
defer {
try? keychain.removePassword(passwordReference, storage: .thisDevice)
try? keychain.removeKeyCredential(missingKeyReference, storage: .thisDevice)
}
let key = SSHKeyRef(
label: "Missing", algorithm: .ed25519, publicKey: "",
fingerprint: "", credentialRef: missingKeyReference
)
let identity = Identity(
name: "Deploy", username: "deploy",
credentialRef: passwordReference, passwordStorage: .thisDevice,
keyID: key.id
)
try keychain.setString("fallback", for: passwordReference, storage: .thisDevice)
let store = HostLibraryModel(keys: [key], identities: [identity], secrets: keychain)
let resolution = store.sshCredentials(for: identity)
#expect(resolution.hasConfiguredCredential)
#expect(resolution.failures.count == 1)
#expect(resolution.credentials.count == 1)
guard case .password(let password) = resolution.credentials.first else {
Issue.record("Expected the available password credential")
return
}
#expect(password == "fallback")
}
@Test
func encryptedKeyPersistsOnlyPrivateMaterialAndRequiresPassphraseAtUse() throws {
let keychain = InMemorySecretStore()
let reference = "termind.key.\(UUID().uuidString)"
defer { try? keychain.removeKeyCredential(reference, storage: .thisDevice) }
let material = try SSHKeyMaterial.generate(
.ed25519, comment: "termind-tests", passphrase: "one-use", rounds: 100
)
let key = SSHKeyRef(
label: "Protected", algorithm: .ed25519, publicKey: material.publicKey,
fingerprint: material.fingerprint, credentialRef: reference,
storage: .thisDevice, hasPassphrase: true
)
let identity = Identity(name: "Deploy", username: "deploy", keyID: key.id)
let store = HostLibraryModel(identities: [identity], secrets: keychain)
try store.upsert(
key,
credential: .software(SSHPrivateKeySecret(privateKey: material.privateKey))
)
guard
case .software(let stored)? = try keychain.keyCredential(
for: reference, storage: .thisDevice
)
else {
Issue.record("Expected the encrypted private-key material")
return
}
#expect(stored.privateKey == material.privateKey)
#expect(store.key(key.id)?.requiresPassphraseEntry == true)
#expect(store.sshCredentials(for: identity).requiredPassphraseKeyID == key.id)
}
@Test
func protectedKeyWithoutSavedPassphraseRequestsItBeforeUse() throws {
let keychain = InMemorySecretStore()
let reference = "termind.key.\(UUID().uuidString)"
defer { try? keychain.removeKeyCredential(reference, storage: .thisDevice) }
let key = SSHKeyRef(
label: "Protected", algorithm: .ed25519, publicKey: "ssh-ed25519 test",
fingerprint: "SHA256:test", credentialRef: reference,
storage: .thisDevice,
hasPassphrase: true
)
let identity = Identity(name: "Deploy", username: "deploy", keyID: key.id)
let store = HostLibraryModel(keys: [key], identities: [identity], secrets: keychain)
try keychain.setKeyCredential(
.software(SSHPrivateKeySecret(privateKey: "encrypted")),
for: reference, storage: .thisDevice
)
let resolution = store.sshCredentials(for: identity)
#expect(resolution.requiredPassphraseKeyID == key.id)
#expect(resolution.credentials.isEmpty)
#expect(resolution.hasConfiguredCredential)
}
@Test(arguments: SSHKeyMaterial.Cipher.allCases)
func generatedKeyCanBeReadWithEveryCipher(_ cipher: SSHKeyMaterial.Cipher) throws {
let material = try SSHKeyMaterial.generate(
.ed25519, comment: "termind-tests", passphrase: "secret",
cipher: cipher, rounds: 100
)
#expect(material.privateKey.contains("BEGIN ENCRYPTED PRIVATE KEY"))
let inspected = try SSHKeyMaterial.inspect(
privateKey: material.privateKey, passphrase: "secret"
)
#expect(inspected.fingerprint == material.fingerprint)
}
@Test
func generatedKeyRejectsNonPositiveRounds() {
#expect(throws: (any Error).self) {
try SSHKeyMaterial.generate(
.ed25519, comment: "termind-tests", passphrase: "secret",
rounds: 0
)
}
}
@Test
func onlyAuthenticationErrorsRequestAnotherCredential() {
#expect(TermindSSHError.authenticationFailed("Rejected").shouldRequestAuthentication)
#expect(!TermindSSHError.socket("Offline").shouldRequestAuthentication)
#expect(!TermindSSHError.library("Handshake").shouldRequestAuthentication)
#expect(
!TermindSSHError.hostKeyChanged(expected: ["a"], received: "b")
.shouldRequestAuthentication)
}
}
private nonisolated final class FailingPasswordRemovalStore: SecretStoring, @unchecked Sendable {
let base = InMemorySecretStore()
func string(for account: String, storage: PasswordStorage) throws -> String? {
try base.string(for: account, storage: storage)
}
func setString(_ value: String?, for account: String, storage: PasswordStorage) throws {
try base.setString(value, for: account, storage: storage)
}
func keyCredential(for account: String, storage: KeyStorage) throws -> StoredSSHKeyCredential? {
try base.keyCredential(for: account, storage: storage)
}
func setKeyCredential(
_ value: StoredSSHKeyCredential?, for account: String, storage: KeyStorage
) throws {
try base.setKeyCredential(value, for: account, storage: storage)
}
func removePassword(_ account: String, storage: PasswordStorage) throws {
throw CocoaError(.fileWriteNoPermission)
}
func removeKeyCredential(_ account: String, storage: KeyStorage) throws {
try base.removeKeyCredential(account, storage: storage)
}
}