405 lines
17 KiB
Swift
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)
|
|
}
|
|
}
|