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.
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:)
If you want the same app-owned persistence boundary but prefer to hide the trust logic inside a dedicated actor or type, use SSHHostKeyTrustStore.
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
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 {
case rejectedFingerprint(String)
}
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.rejectedFingerprint(fingerprint)
}
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.
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
- clearer public error modeling for trust failures