Host Key Trust
Choose a host-key policy and understand the supported trust models.
Why Traversio Requires a Trust Policy
The transport handshake verifies control of the private key behind the presented host key. Traversio keeps the trust decision explicit, so each connection chooses a host-key policy directly. There is no implicit trust-store fallback and no default accept-any mode; if the selected policy cannot prove or store trust, connection setup fails before authentication completes.
Supported public options:
acceptAnyVerifiedHostKeyrequireMatch(SSHTrustedHostKey)requireMatchAny([SSHTrustedHostKey])trustOnFirstUse(lookup:store:)trustOnFirstUse(lookup:store:onStoredHostKeyMismatch:)trustOnFirstUse(using: SSHHostKeyTrustStore)knownHostsFile(String)knownHostsFile(String, additionalLookupNames: [String])callback((SSHHostKeyValidationRequest) async throws -> SSHHostKeyTrustMethod)
Option 1: trustOnFirstUse
If your app wants the simplest "library gives me the verified host key, I store it, next time I compare it" workflow, start here.
actor HostKeyStore {
private var keys: [String: SSHTrustedHostKey] = [:]
func lookup(host: String, port: UInt16) -> SSHTrustedHostKey? {
self.keys["\(host):\(port)"]
}
func store(_ request: SSHHostKeyStoreRequest) throws {
let key = "\(request.endpointHost):\(request.endpointPort)"
let current = self.keys[key]
guard request.matchesExpectedStoredHostKey(current) else {
throw SSHHostKeyPolicyError.concurrentStoredHostKeyUpdate(
endpointHost: request.endpointHost,
endpointPort: request.endpointPort,
expectedStoredHostKey: request.expectedStoredHostKey,
actualStoredHostKey: current
)
}
self.keys[key] = request.trustedHostKey
}
}
let store = HostKeyStore()
let configuration = SSHClientConfiguration(
host: "example.com",
username: "deploy",
authentication: .password(secret),
hostKeyPolicy: .trustOnFirstUse(
lookup: { host, port in
await store.lookup(host: host, port: port)
},
store: { request in
try await store.store(request)
}
)
)Behavior:
- if your
lookupclosure returns no key, Traversio treats this as first use and callsstore - if your
lookupclosure returns a key, Traversio compares it with the newly verified host key storereceives anSSHHostKeyStoreRequestwithexpectedStoredHostKey, so your persistence layer can do compare-and-set instead of blindly overwriting concurrent updates- if the key changed, Traversio throws
SSHHostKeyPolicyError.storedHostKeyMismatch(...)
This keeps persistence in your app while removing the repetitive compare-or-store boilerplate.
Option 1b: trustOnFirstUse(using:)
Use SSHHostKeyTrustStore for the same app-owned persistence boundary with trust logic hidden inside a dedicated actor or type.
actor HostKeyStore: SSHHostKeyTrustStore {
private var keys: [String: SSHTrustedHostKey] = [:]
func lookupHostKey(
endpointHost: String,
endpointPort: UInt16
) async throws -> SSHTrustedHostKey? {
self.keys["\(endpointHost):\(endpointPort)"]
}
func storeHostKey(_ request: SSHHostKeyStoreRequest) async throws {
let key = "\(request.endpointHost):\(request.endpointPort)"
let current = self.keys[key]
guard request.matchesExpectedStoredHostKey(current) else {
throw SSHHostKeyPolicyError.concurrentStoredHostKeyUpdate(
endpointHost: request.endpointHost,
endpointPort: request.endpointPort,
expectedStoredHostKey: request.expectedStoredHostKey,
actualStoredHostKey: current
)
}
self.keys[key] = request.trustedHostKey
}
}
let store = HostKeyStore()
let configuration = SSHClientConfiguration(
host: "example.com",
username: "deploy",
authentication: .password(secret),
hostKeyPolicy: .trustOnFirstUse(using: store)
)Default behavior:
- new host keys are saved through
storeHostKey - matching stored keys are accepted without another write
- changed stored keys are rejected by default
storeHostKey(_:) also receives expectedStoredHostKey, so a shared store can reject stale writes instead of overwriting a newer trust decision.
If your store wants to allow a planned rotation, implement decisionForChangedHostKey(_:) and return .replaceStoredHostKey.
Option 1c: trustOnFirstUse With A Changed-Key Decision
If your app wants the same first-seen flow but needs an explicit decision point when a stored host key changes, use the overload with onStoredHostKeyMismatch.
let configuration = SSHClientConfiguration(
host: "example.com",
username: "deploy",
authentication: .password(secret),
hostKeyPolicy: .trustOnFirstUse(
lookup: { host, port in
await store.lookup(host: host, port: port)
},
store: { request in
try await store.store(request)
},
onStoredHostKeyMismatch: { request in
if isExpectedRotation(
oldFingerprint: request.storedHostKey.fingerprintSHA256,
newFingerprint: request.receivedHostKey.fingerprintSHA256
) {
return .replaceStoredHostKey
}
return .reject
}
)
)Behavior:
- if the host key is new, Traversio still calls
store - if the stored key matches, the connection proceeds without writing anything
- if the stored key changed, your callback receives both the stored and received key and can either:
- return
.rejectto fail withSSHHostKeyPolicyError.storedHostKeyMismatch(...) - return
.replaceStoredHostKeyto persist the new key through the samestoreclosure and continue, while still carryingexpectedStoredHostKeyso your store can reject stale replacements
- return
Option 2: knownHostsFile
For most production applications, this is the practical default.
let configuration = SSHClientConfiguration(
host: "example.com",
username: "deploy",
authentication: .password(secret),
hostKeyPolicy: .knownHostsFile("/Users/me/.ssh/known_hosts")
)Supported OpenSSH known_hosts features:
- exact host names
- exact
[host]:portentries - optional additional lookup names so the configured host plus a resolved IP or alias can both participate in matching
- wildcard host patterns using
*and? - negated host patterns using
! - hashed host entries
- hashed
[host]:portentries - CIDR-style address entries such as
192.0.2.0/24or2001:db8::/64 @revokedhost-key entries@cert-authorityCA entries for host certificates- multiple exact matches for the same endpoint, which become a trusted set
When a host certificate matches an @cert-authority entry, Traversio trusts the CA key from that entry and still enforces the certificate's principal and validity checks before the session is activated.
Live-validated OpenSSH host-certificate algorithms:
Known limits:
- broader host-certificate algorithm coverage beyond the current Ed25519 and ECDSA P-256 certificate paths
Traversal of stored trust data remains application-owned, including persistence.
Lookup behavior:
- port
22looks up the normalized lowercase host name directly, plus anyadditionalLookupNamesyou provided - non-default ports check
[host]:portfirst, plus bracketedadditionalLookupNamessuch as[192.0.2.10]:2222 - if no port-specific entry matches, Traversio falls back to the raw host name
- that raw-name fallback also includes any
additionalLookupNames - if a port-specific trusted or revoked entry does match, Traversio does not widen the lookup back to the raw host name
- if no trusted or CA entry matches, connection setup fails with a known-host failure instead of accepting the server key
When you already know the address or alias you want known_hosts to consider, pass it explicitly:
let configuration = SSHClientConfiguration(
host: "example.com",
port: 2222,
username: "deploy",
authentication: .password(secret),
hostKeyPolicy: .knownHostsFile(
"/Users/me/.ssh/known_hosts",
additionalLookupNames: ["192.0.2.10"]
)
)Option 3: Exact Raw-Key Pinning
If you already have the raw host-key bytes, you can pin them directly with SSHTrustedHostKey.
let rawHostKey: [UInt8] = /* bytes from a trusted source */
let trustedHostKey = try SSHTrustedHostKey(rawRepresentation: rawHostKey)
let configuration = SSHClientConfiguration(
host: "example.com",
username: "deploy",
authentication: .password(secret),
hostKeyPolicy: .requireMatch(trustedHostKey)
)You can inspect the computed fingerprint through:
let fingerprint = trustedHostKey.fingerprintSHA256Option 4: Trust a Small Exact Set
When you need to support host-key rotation or more than one exact key, use requireMatchAny.
let trustedKeys = try [
SSHTrustedHostKey(rawRepresentation: key1),
SSHTrustedHostKey(rawRepresentation: key2),
]
let configuration = SSHClientConfiguration(
host: "example.com",
username: "deploy",
authentication: .password(secret),
hostKeyPolicy: .requireMatchAny(trustedKeys)
)Option 5: Accept Any Verified Host Key
let configuration = SSHClientConfiguration(
host: "example.com",
username: "deploy",
authentication: .password(secret),
hostKeyPolicy: .acceptAnyVerifiedHostKey
)This mode fits disposable test environments, local bring-up, or controlled tooling. Production deployments usually choose a stricter trust policy.
Option 6: Custom Trust Callback
Use callback(...) when your application needs full control over the trust decision.
enum HostTrustError: Error, SSHCallbackFailureDiagnosticProviding {
case rejectedByUser
var sshCallbackFailureDiagnosticCode: String {
"host-key-rejected"
}
var sshCallbackFailureDiagnosticSummary: String? {
"The host-key trust decision was rejected by application policy."
}
}
let configuration = SSHClientConfiguration(
host: "example.com",
username: "deploy",
authentication: .password(secret),
hostKeyPolicy: .callback { request in
let fingerprint = request.trustedHostKey.fingerprintSHA256
guard isAllowed(fingerprint: fingerprint) else {
throw HostTrustError.rejectedByUser
}
return .callback
}
)The callback receives:
endpointHostendpointPortremoteIdentificationtrustedHostKeymatches(_:)
That is enough to consult your application's trust store, write a first-seen record, or reject a changed key before Traversio continues.
If the callback throws an error that conforms to SSHCallbackFailureDiagnosticProviding, connection failure diagnostics preserve its sshCallbackFailureDiagnosticCode and optional safe summary under diagnostics.callbackFailure. Use that for app-owned distinctions such as user rejection, unavailable interaction context, persistence failure, or concurrent trust-store updates without parsing failure prose or dumping raw key material.
What Metadata You Get After Trust Succeeds
After the connection is established, SSHConnection.metadata records:
hostKeyAlgorithmhostKeyFingerprintSHA256hostKeyTrustMethod
That lets you log what actually happened instead of assuming which trust path was used.
Remaining Gaps
Host trust still has a few open areas:
- broader host-certificate algorithm coverage and any CA-source behavior beyond today's Ed25519 and ECDSA P-256 plus
known_hosts @cert-authorityfloor - any additional trust helpers that prove necessary on top of app-owned persistence
- additional built-in semantic categories if real app integrations prove they should be Traversio-owned rather than app-owned callback diagnostics